Compare commits

..

56 Commits

Author SHA1 Message Date
marvin 98c4fd331d Add a live preview pane to Gateway Defaults for the themed page and Custom HTML (v0.11.91) 2026-09-15 00:18:29 -04:00
marvin 60f36dbfb7 Fix caddyQuote() double-escaping backslashes, breaking the Block-common-exploits regex (v0.11.90) 2026-09-15 00:02:15 -04:00
marvin fb961ae96d Simplify Block-common-exploits checkbox, reset Advanced-options state per dialog open, expand its documentation (v0.11.89) 2026-09-14 23:49:31 -04:00
marvin d98642cbc2 Fix Load balancing dropdown enabling prematurely; clarify Forward-to/Upstream pool interaction (v0.11.88) 2026-09-14 23:36:04 -04:00
marvin 56e0583b2d Add load-balancing policy selection for Proxy Hosts with multiple upstreams (v0.11.87) 2026-09-14 23:26:23 -04:00
marvin 8e430a9127 Confirm before disabling a Group that's still granting Access List login (v0.11.86) 2026-09-14 22:28:38 -04:00
marvin 4576845604 Always land on the dashboard after a successful login (v0.11.84) 2026-09-14 22:14:16 -04:00
marvin 987cf48b6c Document My Account, 2FA, and the update-notification banner in the in-app manual (v0.11.83) 2026-09-14 22:09:27 -04:00
marvin e62f0cc988 Show an update-available banner when a newer version is deployed (v0.11.82) 2026-09-14 21:56:58 -04:00
marvin 823a116d09 Tighten My Account password-form spacing (v0.11.81) 2026-09-14 21:45:42 -04:00
marvin fd414ddaf4 Tighten brand-logo spacing and clear password field on MFA cancel (v0.11.80) 2026-09-14 21:38:33 -04:00
marvin 89cabf4baf Fix login username-focus race and tighten login/MFA spacing (v0.11.79) 2026-09-14 21:14:37 -04:00
marvin f39d405de8 Fix My Account layout: password form grid gap and panel spacing (v0.11.78) 2026-09-14 19:59:55 -04:00
marvin 2bb875eb98 Add optional two-factor authentication (TOTP) and a self-service My Account view (v0.11.77) 2026-09-14 19:49:23 -04:00
marvin d73f0cea3f Fix domain-less-primary Hosted Sites not getting a Caddy site block (v0.11.76) 2026-09-14 10:42:08 -04:00
marvin 72b2dc7458 Fix stale JS cache-bust versions (v0.11.75) 2026-09-14 10:32:42 -04:00
marvin b17fce9adb Groups toggle instant feedback, fix site domains and proxy cert-upload on edit (v0.11.74) 2026-09-14 10:14:58 -04:00
marvin 1ff8b6691d Center sidebar brand icon/wordmark (v0.11.73) 2026-09-14 09:45:39 -04:00
marvin 97d62a1c3b Larger centered brand marks, clickable sidebar logo, block common exploits toggle (v0.11.72) 2026-09-14 09:40:43 -04:00
marvin 94a651e4e5 Icon-on-top brand layout with new transparent icon/wordmark (v0.11.71) 2026-09-14 09:23:14 -04:00
marvin d30357777e Security & Updates panel restructure; Performance error-count tooltip with status breakdown 2026-09-13 22:01:07 -04:00
marvin 8c62eaca9d Documentation: add Dashboard and Performance sections, update Users menu reference 2026-09-13 21:51:24 -04:00
marvin 1050cf9144 Performance table: merge error counts into their duration columns, center numeric columns 2026-09-13 21:41:57 -04:00
marvin 538a617709 Performance view: move Domain/Range filters inside the Trend panel to match Logs 2026-09-13 21:22:13 -04:00
marvin eeecf7b586 Logs view: wrap Access requests in a dashboard-panel to match Gateway events 2026-09-13 20:45:37 -04:00
marvin 2c4be2bf42 Users: move Delete into options menu, fix missing dropdown arrow on role select 2026-09-13 20:36:50 -04:00
marvin 01035371ff Performance tab: add time-range selector, fix gap between Trend and Per-route panels 2026-09-13 20:23:33 -04:00
marvin d24edf4d6a Fix distorted sparkline labels: render as HTML overlay instead of SVG text 2026-09-13 20:15:35 -04:00
marvin 4e624323e4 Fix sparkline scaling: restore preserveAspectRatio, taller chart 2026-09-13 20:09:56 -04:00
marvin 02271d9072 Performance tab: sparkline axis, per-route card, fix table overflow/truncation 2026-09-13 20:03:26 -04:00
marvin 73edc8e665 Fix layout imbalance when Needs Attention collapses to all-clear banner (v0.11.60) 2026-09-13 19:43:35 -04:00
marvin f0857612e9 Live Health streaming/upstream tiles, tiled attention/activity panels with severity coloring and relative timestamps (v0.11.59) 2026-09-13 19:32:39 -04:00
marvin 1f0082d9ac Dashboard consistency batch: tiled scheduled jobs, unified readiness/certificate accordions, public IP check, performance throughput tab (v0.11.58) 2026-09-13 19:08:00 -04:00
marvin 5cce273b2c Move Scheduled Jobs panel directly below Live Health/Runtime row 2026-09-13 18:13:28 -04:00
marvin a857f8be0e Keep Live Health and Runtime/System permanently side by side; move Scheduled Jobs to its own row 2026-09-13 18:07:44 -04:00
marvin 6c3ef9f57b Fix Runtime panel reflow (broken selector after health-list rename) and light-theme chip backgrounds 2026-09-13 17:46:24 -04:00
marvin df318bed4f Match System panel styling to Live Health: tile grid, accent bar, light-theme tile fix 2026-09-13 17:39:36 -04:00
marvin bebf4322c0 Fix browser back/forward navigation: push a history entry per view change instead of replacing it 2026-09-13 17:32:09 -04:00
marvin 799276eaa1 Redesign Live Health panel: status-driven accent bar, tile grid layout, live-check pulse indicator 2026-09-13 17:29:03 -04:00
marvin 5f43f87474 Align dashboard icons with each page's existing icon, add icons to hosted/proxy/certificate tiles 2026-09-13 17:21:57 -04:00
marvin efc48fbff2 Redesign dashboard metrics: surface Redirect/Streaming hosts, fix Needs Attention status color, add marketing-site accent palette 2026-09-13 17:11:03 -04:00
marvin c8dade1e6e Add optimistic UI feedback and smooth transition to enable/disable toggles 2026-09-13 16:23:48 -04:00
marvin fe98cd64b0 Stop search from scrolling away and fix lingering nav button focus outline 2026-09-13 15:51:25 -04:00
marvin 731d41377c Documentation search shows only the single best match instead of a scrollable list 2026-09-13 14:29:24 -04:00
marvin 05ec32ff3c Rank documentation search results by title/keyword match before body text 2026-09-13 14:15:53 -04:00
marvin 34b7dbfdc3 Fix blue glow persisting on long pages by anchoring gradient to viewport 2026-09-13 13:53:39 -04:00
marvin edb5d58bb4 Fix styles.css cache-busting so CSS updates always reach the browser 2026-09-13 13:43:58 -04:00
marvin 34d7c2a77f Fix blue banner at top of Documentation page in dark theme 2026-09-13 13:31:31 -04:00
marvin 46360a2453 Fix Documentation section theming to follow light/dark mode like other pages 2026-09-13 13:21:41 -04:00
marvin a7201fbb8c Rewrite Documentation section: reorganize and cover every configurable field 2026-09-13 12:55:16 -04:00
marvin 44777072d5 Fix: remove stray JS that repositioned the summary bar after every load 2026-09-13 00:36:26 -04:00
marvin 575c1816c3 Fix: match Streaming Hosts summary bar, toggle, and dialog theming to Hosted/Proxy 2026-09-13 00:09:10 -04:00
marvin 9a4f91d40d Fix: register streams collection and stream_hosts table in storage.js 2026-09-12 23:48:22 -04:00
marvin dbc8f3490b Add Streaming Hosts: native TCP/UDP port forwarding with monitoring
Also adds scripts/apply-patch.sh to automate applying patches dropped
in ~/Downloads/Claude outputs.
2026-09-13 03:31:00 +00:00
marvin d2aed579f7 Trigger an immediate upstream health check when a proxy host's PATCH settings change 2026-09-12 22:45:21 -04:00
marvin 4a9cba5f8e Fix proxy host monitor-checkbox save/display bug; polish Certificates page loading text and spacing 2026-09-12 22:25:48 -04:00
13 changed files with 1138 additions and 196 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod --frozen-lockfile
RUN pnpm install --prod --no-frozen-lockfile
COPY src ./src
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.11.32",
"version": "0.11.91",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
@@ -11,7 +11,8 @@
"dependencies": {
"adm-zip": "0.5.16",
"express": "5.1.0",
"multer": "2.0.2"
"multer": "2.0.2",
"qrcode": "1.5.4"
},
"engines": {
"node": ">=22"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# apply-patch.sh — picks up the newest Claude-generated .patch file from
# ~/Downloads/Claude outputs, copies it into this repo, applies it, and
# cleans up both copies. Run from anywhere; it finds the repo root itself.
#
# Usage: ./scripts/apply-patch.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
SOURCE_DIR="$HOME/Downloads/Claude outputs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "Can't find $SOURCE_DIR" >&2
exit 1
fi
PATCH=$(ls -t "$SOURCE_DIR"/*.patch 2>/dev/null | head -n1 || true)
if [ -z "$PATCH" ]; then
echo "No .patch file found in $SOURCE_DIR"
exit 1
fi
NAME=$(basename "$PATCH")
DEST="$REPO_ROOT/$NAME"
echo "Found patch: $NAME"
cp "$PATCH" "$DEST"
echo "Checking that it applies cleanly..."
if ! git apply --check "$DEST" 2>/tmp/apply-patch-check.log; then
echo
echo "Patch does NOT apply cleanly against the current branch. Nothing was changed."
echo "Details:"
cat /tmp/apply-patch-check.log
echo
echo "The copied patch is left at: $DEST"
echo "The original is untouched at: $PATCH"
exit 1
fi
echo "Applying..."
git apply "$DEST"
echo "Cleaning up..."
rm -f "$DEST"
rm -f "$PATCH"
echo
echo "Applied and cleaned up ($NAME removed from both this repo and Claude outputs)."
echo "Changed files:"
git status --short
echo
echo "Review with: git diff --stat"
echo "Then commit and push yourself when ready."
+23
View File
@@ -0,0 +1,23 @@
diff -ruN a/package.json b/package.json
--- a/package.json 2026-09-13 18:26:23.565353612 +0000
+++ b/package.json 2026-09-13 18:26:23.579589390 +0000
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
- "version": "0.11.45",
+ "version": "0.11.46",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
diff -ruN a/src/public/features.js b/src/public/features.js
--- a/src/public/features.js 2026-09-13 18:26:23.584420397 +0000
+++ b/src/public/features.js 2026-09-13 18:26:23.586550870 +0000
@@ -126,7 +126,7 @@
document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } });
const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; });
-document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"smooth", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query); article.classList.toggle("hidden", !match); article.style.order = query && match ? (topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2) : ""; if (match) visible++; }); document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); });
+document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"instant", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0, topResult = null, topOrder = Infinity; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query), order = topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2; article.classList.toggle("hidden", !match); article.style.order = query && match ? order : ""; if (match) { visible++; if (query && order < topOrder) { topOrder = order; topResult = article; } } }); document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); if (query && topResult) topResult.scrollIntoView({ behavior:"instant", block:"start" }); });
document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open"));
document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); }));
+280 -69
View File
@@ -1,8 +1,5 @@
const $ = selector => document.querySelector(selector);
const summaryBar = document.querySelector("#management-summary");
const redirectView = document.querySelector("#redirects-view");
if (summaryBar && redirectView) redirectView.parentElement.insertBefore(summaryBar, redirectView);
const state = { sites: [], proxies: [], redirects: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null };
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false };
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
@@ -27,7 +24,7 @@ async function api(url, options = {}) {
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
return response.status === 204 ? null : response.json();
}
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; }
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
@@ -48,6 +45,20 @@ function formatTime(value) {
if (!value) return "Just now";
const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
function formatRelativeTime(value) {
if (!value) return "Just now";
const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently";
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
if (seconds < 45) return "Just now";
if (seconds < 90) return "1 minute ago";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} minutes ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.round(hours / 24);
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
return formatTime(value);
}
function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); }
function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); }
function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
@@ -56,13 +67,24 @@ function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#sett
// and silently dropped the whole save. Fall back to the form's actual submit button.
function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); }
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
function advancedFormBody(form, body) {
// #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the
// hidden site-scoped (#settings-hosted-advanced) and proxy-scoped (#settings-advanced) sections. form.elements.NAME
// resolves to a RadioNodeList when a name is duplicated, and assigning .value/.checked to a RadioNodeList of
// non-radio inputs silently does nothing — so every one of these fields must be read/written through its scope.
function setScoped(form, scope, name, value) { const el = form.querySelector(`${scope} [name="${name}"]`); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(value); else el.value = value; }
function advancedFormBody(form, body, scoped) {
// scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where
// field names collide with another section, so every ambiguous field is read from its own scope instead of
// trusting the unscoped FormData value (which can silently pick up the other section's field).
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name);
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
body.hsts = form.has("hsts"); body.hstsSubdomains = form.has("hstsSubdomains"); body.healthEnabled = body.healthEnabled === true || body.healthEnabled === "on"; body.upstreamTlsInsecure = form.has("upstreamTlsInsecure");
body.requestHeaders = parseHeaderLines(form.get("requestHeadersText")); body.responseHeaders = parseHeaderLines(form.get("responseHeadersText")); body.compression = form.get("compression") || "automatic"; body.customConfig = form.get("customConfig") || "";
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits");
body.accessListId = read("accessListId", body.accessListId || "");
body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig");
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean);
body.healthPath = form.get("healthPath") || "/"; body.healthMethod = form.get("healthMethod") || "GET"; body.healthExpected = form.get("healthExpected") || "200-499"; body.healthTimeoutSeconds = Number(form.get("healthTimeoutSeconds") || 4); body.healthRetries = Number(form.get("healthRetries") || 0);
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean); body.lbPolicy = read("lbPolicy", "random");
body.healthPath = read("healthPath", "/"); body.healthMethod = read("healthMethod", "GET"); body.healthExpected = read("healthExpected", "200-499"); body.healthTimeoutSeconds = Number(read("healthTimeoutSeconds", "4")); body.healthRetries = Number(read("healthRetries", "0"));
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
return body;
}
@@ -71,11 +93,21 @@ document.addEventListener("submit", async event => {
if (event.target?.id !== "settings-form" || !state.editing) return;
event.preventDefault(); event.stopImmediatePropagation();
const form = new FormData(event.target), button = resolveSubmitter(event);
const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile");
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
if (state.editing.kind === "proxy") body = advancedFormBody(form, body);
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target });
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size;
if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) {
const existing = state.proxies.find(item => item.id === state.editing.id);
if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; return; }
}
button.disabled = true;
try { await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); }
try {
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied.");
}
catch (error) { $("#settings-error").textContent = error.message; }
finally { button.disabled = false; }
}, true);
@@ -105,12 +137,19 @@ function renderDashboard() {
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
$("#dash-tls-total").textContent = data.tlsDomains;
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
$("#dash-redirect-total").textContent = state.redirects?.length || 0;
$("#dash-stream-total").textContent = state.streams?.length || 0;
$("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const overall = $("#overall-health");
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
$("#health-panel").className = `dashboard-panel health-panel ${hasErrors ? "status-error" : isChecking || hasNothingRunning ? "status-warning" : "status-healthy"}`;
$("#gateway-health-dot").className = `status-dot ${probeClass(data.gateway)}`;
$("#gateway-health-copy").textContent = probeCopy(data.gateway, data.gateway.lastReload ? `Ready · reloaded ${formatTime(data.gateway.lastReload)}` : "Ready and responding", "Caddy is not responding");
$("#http-health-dot").className = `status-dot ${probeClass(data.services.http)}`;
@@ -119,7 +158,13 @@ function renderDashboard() {
$("#https-health-copy").textContent = probeCopy(data.services.https, `Ready and responding · ${data.services.https.activeDomains} TLS domain${data.services.https.activeDomains === 1 ? "" : "s"}`, "Not responding", "Not configured · no TLS domains enabled");
$("#storage-health-dot").className = `status-dot ${data.services.storage.healthy ? "running" : "error"}`;
$("#storage-health-copy").textContent = data.services.storage.healthy ? "Ready · /data is readable and writable" : "Permission error · check /data";
$("#health-checked").textContent = `Last checked ${formatTime(data.checkedAt)}`;
const streaming = data.streamingPorts || { total: 0, listening: 0 };
$("#streaming-health-dot").className = `status-dot ${!streaming.total ? "inactive" : streaming.listening === streaming.total ? "running" : "error"}`;
$("#streaming-health-copy").textContent = !streaming.total ? "No streaming hosts configured" : `${streaming.listening} of ${streaming.total} port${streaming.total === 1 ? "" : "s"} listening`;
const upstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 };
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`;
$("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`;
$("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
updateDashboardUptime(data.system.uptimeSeconds);
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
$("#system-data").textContent = formatBytes(data.system.dataBytes);
@@ -129,8 +174,12 @@ function renderDashboard() {
$("#system-caddy-version").textContent = data.system.caddyVersion;
$("#system-database").textContent = `${data.system.databaseEngine} · ${data.system.databaseStatus}`;
$("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`;
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="dashboard-list-item issue ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<p class="quiet-state">Everything looks good.</p>';
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="dashboard-list-item"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(formatTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
$("#system-public-ip").textContent = data.system.publicIp || (data.system.publicIpError ? "Unavailable" : "Checking…");
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
}
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
@@ -178,21 +227,11 @@ function renderReadiness() {
const upstreamOk = !item.upstream || item.upstream.status === "healthy";
const check = item.upstream;
const message = !dnsOk ? `DNS failed${item.dns.error ? ` · ${item.dns.error}` : ""}` : !item.ports.http ? "HTTP port 80 is not responding inside the container" : item.ports.https === false ? "HTTPS port 443 is not responding inside the container" : !tlsOk ? `TLS ${item.tls.status.replaceAll("-", " ")}` : !upstreamOk ? `Upstream ${check?.error || "unavailable"}` : `Ready · DNS ${item.dns.addresses.join(", ")}${check ? ` · upstream ${check.httpStatus || "responding"}` : ""}`;
return `<div class="dashboard-list-item readiness-row" role="button" tabindex="0" data-readiness-id="${escapeHtml(item.id)}" aria-label="View diagnostics for ${escapeHtml(item.domain)}"><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small><span class="readiness-hint">Click to view diagnostics</span></span></div>`;
const upstreamDetail = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
return `<details class="certificate-row readiness-row"><summary><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small></span></summary><dl class="certificate-details"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstreamDetail}</dl></details>`;
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
}
function showReadinessDetails(item) {
const check = item.upstream;
const upstream = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
$("#readiness-title").textContent = item.domain;
$("#readiness-detail-content").innerHTML = `<dl class="readiness-detail-grid"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstream}</dl>`;
$("#readiness-dialog").showModal();
}
$("#readiness-list").addEventListener("click", event => { const row = event.target.closest("[data-readiness-id]"); const item = state.readiness?.routes?.find(route => route.id === row?.dataset.readinessId); if (item) showReadinessDetails(item); });
$("#readiness-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest("[data-readiness-id]"); if (row) { event.preventDefault(); row.click(); } });
function renderLogs() {
const data = state.logs; if (!data) return;
const selected = $("#log-host").value; $("#log-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join(""); $("#log-host").value = selected;
@@ -206,6 +245,57 @@ function renderLogs() {
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>';
}
function renderPerformance() {
const data = state.performance; if (!data) return;
const selected = $("#performance-host").value;
$("#performance-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join("");
$("#performance-host").value = selected;
const label = selected ? escapeHtml(selected) : "all domains";
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || [];
const max = Math.max(1, ...points.map(point => point.count));
const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
const yAt = count => top + plotHeight - (count / max) * plotHeight;
const gridFractions = [0, 0.5, 1];
const gridLines = gridFractions.map(fraction => {
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
}).join("");
const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const axisLabels = gridFractions.map(fraction => {
const value = Math.round(max * fraction);
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
}).join("");
const firstPoint = points[0], lastPoint = points[points.length - 1];
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
const p1 = coords[index - 1];
const p2 = point;
const p3 = coords[index + 1] || point;
const cp1x = p1[0] + (p2[0] - p0[0]) / 6, cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6, cp2y = p2[1] - (p3[1] - p1[1]) / 6;
return `${d} C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}, "");
const baseline = (top + plotHeight).toFixed(1);
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
const routes = data.routes || [];
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
}
function renderUsers() {
const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 };
state.users.forEach(user => { if (user.status === "active") counts[user.role] = (counts[user.role] || 0) + 1; else if (counts[user.status] !== undefined) counts[user.status] += 1; });
@@ -218,34 +308,48 @@ function renderUsers() {
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
const deleteAction = !isSelf ? `<button class="button secondary danger-text" data-user-action="delete">Delete</button>` : "";
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}${deleteAction}</div><div class="card-footer">${statusToggle}</div></article>`;
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
}).join("") : '<p class="quiet-state">No users found.</p>';
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const user = state.users.find(item => item.id === card.dataset.userId); const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!user || !head || !footer) return; if (status) footer.prepend(status); const menu = document.createElement("div"); menu.className = "menu-wrap"; menu.innerHTML = '<button class="icon-button" type="button" aria-label="Change user icon">•••</button>'; menu.querySelector("button").addEventListener("click", () => openIconPicker("users", user.id)); head.append(menu); });
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.style.cssText = "height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);line-height:42px"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
}
function renderAccount() {
if (!state.user) return;
$("#account-display-name").textContent = state.user.displayName || "—";
$("#account-username").textContent = state.user.username || "—";
$("#account-role").textContent = state.user.role === "administrator" ? "Administrator" : state.user.role === "viewer" ? "Viewer" : "Standard User";
const enabled = Boolean(state.user.mfaEnabled);
const pill = $("#account-mfa-status");
pill.innerHTML = `<span class="status-dot ${enabled ? "running" : "inactive"}"></span>${enabled ? "On" : "Off"}`;
$("#account-mfa-enable").classList.toggle("hidden", enabled);
$("#account-mfa-disable").classList.toggle("hidden", !enabled);
$("#account-mfa-recovery").classList.toggle("hidden", !enabled);
}
async function loadFeatureView() {
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); }
if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
if (state.view === "performance") { state.performance = await api(`/api/performance?host=${encodeURIComponent($("#performance-host").value)}&hours=${encodeURIComponent($("#performance-range").value || "6")}`); renderPerformance(); }
if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); renderUsers(); window.renderExtendedViews?.(); }
if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.();
restoreAdminTab();
}
function render() {
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
if (location.hash !== `#${viewHash}`) history.replaceState(null, "", `${location.pathname}${location.search}#${viewHash}`);
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = "0"; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`);
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = state.streams.length; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
document.querySelectorAll("nav [data-view], .aside-utilities [data-view]").forEach(button => button.classList.toggle("nav-active", button.dataset.view === state.view));
const overview = state.view === "overview";
$("#dashboard-view").classList.toggle("hidden", !overview);
const management = state.view === "hosted" || state.view === "proxies" || state.view === "streaming";
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "redirects" || state.view === "access"));
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#users-view").classList.toggle("hidden", state.view !== "administration");
const management = state.view === "hosted" || state.view === "proxies";
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access"));
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
$("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -253,33 +357,35 @@ function render() {
return;
}
if (!management) {
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."] };
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
$("#open-create").textContent = state.view === "administration" ? " Create user" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
$("#open-create").textContent = state.view === "administration" ? " Create user" : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
if (state.view === "redirects") $("#redirect-empty").classList.toggle("hidden", !state.loaded || state.redirects.length > 0);
if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); }
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs();
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); else if (state.view === "performance") renderPerformance(); else if (state.view === "account") renderAccount();
return;
}
const items = state.view === "hosted" ? state.sites : state.view === "proxies" ? state.proxies : [];
const items = state.view === "hosted" ? state.sites : state.proxies;
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : state.view === "proxies" ? "Create your first proxy host" : "Create your first streaming host";
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : state.view === "proxies" ? "Connect a domain to another container, application, or LAN service." : "Streaming host management is coming soon.";
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : state.view === "proxies" ? "Proxy hosts" : "Streaming hosts";
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : state.view === "proxies" ? "Route domains securely to applications and containers." : "Prepare and monitor streaming services from one place.";
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : "Create your first proxy host";
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : "Connect a domain to another container, application, or LAN service.";
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : "Proxy hosts";
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : "Route domains securely to applications and containers.";
$("#open-create").textContent = state.view === "hosted" ? " New hosted site" : " New proxy host";
$("#open-create").classList.toggle("hidden", state.view === "streaming" || !canManage());
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : state.view === "proxies" ? "Create a proxy host" : "Streaming hosts coming soon";
$("#empty .create-trigger").disabled = state.view === "streaming";
$("#open-create").classList.toggle("hidden", !canManage());
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : "Create a proxy host";
$("#empty .create-trigger").disabled = false;
$(".port-note").classList.toggle("hidden", state.view === "proxies");
const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length;
$("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors;
$("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues";
$("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`;
}
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
async function refreshPendingProxies(ids = []) {
const pending = new Set(ids.map(String));
for (const delay of [1000, 2000, 3000]) {
@@ -290,7 +396,7 @@ async function refreshPendingProxies(ids = []) {
}
}
async function refreshDashboard() {
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").textContent = "Checking services…";
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); }
}
@@ -304,12 +410,23 @@ async function boot() {
if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; }
state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config");
$("#version-label").textContent = `v${state.config.version || "unknown"}`;
if (!state.loadedVersion) state.loadedVersion = state.config.version;
$("#port-range").textContent = `${state.config.minPort}${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}${state.config.maxPort}`;
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
}
async function checkForUpdate() {
if (state.updateAvailable || !state.loadedVersion) return;
const config = await api("/api/config");
if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); }
}
$("#update-banner-refresh").addEventListener("click", () => location.reload());
$("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; });
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
$("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } });
$("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#login-form").elements.password.value = ""; setTimeout(() => $("#login-form").elements.password.focus(), 0); });
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
@@ -317,20 +434,22 @@ $("#check-health").addEventListener("click", async event => { const button = eve
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
document.querySelectorAll("nav, .aside-utilities").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
function openCreate() {
if (state.view === "streaming") return toast("Streaming host management is coming soon.");
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
$("#create-form").reset(); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
if (state.view === "proxies") { $("#proxy-form").reset(); $("#proxy-form").querySelectorAll("details").forEach(details => details.open = false); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
$("#create-form").reset(); $("#create-form").querySelectorAll("details").forEach(details => details.open = false); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
}
$("#open-create").addEventListener("click", openCreate);
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
@@ -344,22 +463,29 @@ function ensureHostedHealthFields() { [document.querySelector("#create-form deta
setInterval(ensureHostedHealthFields, 300);
function openSettings(kind, id) {
if (kind === "hosted") kind = "site";
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset();
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset(); form.querySelectorAll("details").forEach(details => details.open = false);
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
if (kind === "proxy") { form.elements.accessListId.value = item.accessListId || ""; form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthEnabled.checked = item.healthEnabled !== false; form.elements.compression.value = item.compression || "automatic"; form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n"); form.elements.requestHeadersText.value = (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.responseHeadersText.value = (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; form.elements.upstreamTlsInsecure.checked = Boolean(item.upstreamTlsInsecure); form.elements.hstsSubdomains.checked = Boolean(item.hstsSubdomains); form.elements.customConfig.value = item.customConfig || ""; }
if (kind === "proxy") form.elements.healthMethod.value = item.healthMethod || "GET";
if (kind === "site") { form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthMethod.value = item.healthMethod || "GET"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthRetries.value = item.healthRetries || 0; form.elements.healthEnabled.checked = item.healthEnabled !== false; }
$("#settings-error").textContent = ""; if (kind === "proxy" && form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
if (kind === "proxy") {
const scope = "#settings-advanced";
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "blockCommonExploits", Boolean(item.blockCommonExploits));
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"));
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random");
}
if (kind === "site") {
const scope = "#settings-hosted-advanced";
setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthRetries", item.healthRetries || 0); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || "");
}
$("#settings-error").textContent = ""; if (form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
}
$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } });
$("#site-grid").addEventListener("click", async event => {
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
closeMenus();
if (action === "toggle") { const base = kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); }
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } }
if (action === "settings") openSettings(kind, card.dataset.id);
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
@@ -389,7 +515,7 @@ $("#icon-search").addEventListener("input", event => {
}, 280);
});
async function saveIcon(slug) {
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
$("#icon-error").textContent = "";
try {
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
@@ -401,12 +527,12 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
$("#icon-upload").addEventListener("change", async event => {
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
$("#save-icon-url").addEventListener("click", async () => {
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
@@ -420,12 +546,17 @@ $("#user-form").addEventListener("submit", async event => {
});
function themedUserConfirm(message, title = "Confirm action") { let dialog = document.querySelector("#user-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "user-confirm-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>${escapeHtml(title)}</h2></div></div><p class="muted">${escapeHtml(message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Confirm</button></div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); }
$("#user-list").addEventListener("click", async event => {
const menuCard = event.target.closest(".user-card");
if (menuCard && event.target.closest(".menu-button")) { const opening = !menuCard.classList.contains("menu-open"); closeMenus(); menuCard.classList.toggle("menu-open", opening); menuCard.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
const button = event.target.closest("[data-user-action]"); if (!button) return;
const card = button.closest("[data-user-id]"); const user = state.users.find(item => item.id === card?.dataset.userId); if (!user) return;
if (button.dataset.userAction === "icon") { closeMenus(); openIconPicker("users", user.id); return; }
if (button.dataset.userAction === "password") {
closeMenus();
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
}
if (button.dataset.userAction === "delete") {
closeMenus();
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
button.disabled = true;
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; }
@@ -467,6 +598,9 @@ function syncUpstreamTlsControls(form) {
if (tlsSkip && !https) tlsSkip.checked = false;
const help = tlsSkip?.closest("label")?.querySelector("small");
if (help) help.textContent = https ? "Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate." : "Available only when the upstream uses HTTPS.";
const lbPolicy = form.elements.lbPolicy;
if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; }
}
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
@@ -474,5 +608,82 @@ document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpst
document.addEventListener("click", event => { if (event.target.closest(".create-trigger,[data-action=edit],[data-card-action=edit]")) setTimeout(() => { syncUpstreamTlsControls(document.querySelector("#proxy-form")); syncUpstreamTlsControls(document.querySelector("#settings-form")); }, 0); });
document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); });
setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500);
function renderDashboardJobsSafe(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"); if (!columns) return; const health = document.querySelector("[data-dashboard-health]") || columns.querySelector(".health-list")?.closest("section"); if (health) { health.dataset.dashboardHealth = "true"; if (health.parentElement === columns) columns.parentElement.insertBefore(health, columns); } let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || state.editing?.kind !== "site") return; event.preventDefault(); event.stopImmediatePropagation(); const button = resolveSubmitter(event); button.disabled = true; const form = new FormData(event.target); try { await api(`/api/sites/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: form.get("domain"), domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: form.get("tls"), hsts: form.has("hsts"), accessListId: form.get("accessListId") || "", healthEnabled: form.has("healthEnabled"), healthPath: form.get("healthPath") || "/", healthMethod: form.get("healthMethod") || "GET", healthExpected: form.get("healthExpected") || "200-499", healthTimeoutSeconds: Number(form.get("healthTimeoutSeconds") || 4), healthRetries: Number(form.get("healthRetries") || 0), compression: form.get("compression") || "automatic", requestHeaders: parseHeaderLines(form.get("requestHeadersText")), responseHeaders: parseHeaderLines(form.get("responseHeadersText")), hstsSubdomains: form.has("hstsSubdomains"), customConfig: form.get("customConfig") || "" }) }); document.querySelector("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { document.querySelector("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true);
function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
$("#account-password-form").addEventListener("submit", async event => {
event.preventDefault();
$("#account-password-error").textContent = "";
const form = event.target;
const body = Object.fromEntries(new FormData(form));
if (String(body.newPassword) !== String(body.confirmPassword)) { $("#account-password-error").textContent = "The new passwords do not match."; return; }
try {
await api("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: body.currentPassword, newPassword: body.newPassword }) });
form.reset(); toast("Password changed.");
} catch (error) { $("#account-password-error").textContent = error.message; }
});
let mfaPasswordResolve = null;
function requestMfaPassword(title, heading) {
$("#mfa-password-title").textContent = title;
$("#mfa-password-heading").textContent = heading;
$("#mfa-password-error").textContent = "";
$("#mfa-password-form").reset();
$("#mfa-password-dialog").showModal();
return new Promise(resolve => { mfaPasswordResolve = resolve; });
}
$("#mfa-password-form").addEventListener("submit", event => {
event.preventDefault();
const password = new FormData(event.target).get("password");
$("#mfa-password-dialog").close();
mfaPasswordResolve?.(password);
mfaPasswordResolve = null;
});
$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; });
$("#account-mfa-enable").addEventListener("click", async () => {
try {
const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
$("#mfa-setup-qr").innerHTML = result.qrSvg;
$("#mfa-setup-secret").textContent = result.secret;
$("#mfa-setup-error").textContent = "";
$("#mfa-setup-confirm-form").reset();
$("#mfa-setup-dialog").showModal();
} catch (error) { toast(error.message); }
});
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
event.preventDefault();
$("#mfa-setup-error").textContent = "";
try {
const code = new FormData(event.target).get("code");
const result = await api("/api/account/mfa/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
$("#mfa-setup-dialog").close();
state.user.mfaEnabled = true;
renderAccount();
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
$("#mfa-recovery-dialog").showModal();
toast("Two-factor authentication enabled.");
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
});
$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); });
$("#account-mfa-disable").addEventListener("click", async () => {
const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue");
if (!password) return;
try {
await api("/api/account/mfa/disable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
state.user.mfaEnabled = false;
renderAccount();
toast("Two-factor authentication disabled.");
} catch (error) { toast(error.message); }
});
$("#account-mfa-recovery").addEventListener("click", async () => {
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
if (!password) return;
try {
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
$("#mfa-recovery-dialog").showModal();
toast("Recovery codes regenerated. Your old codes no longer work.");
} catch (error) { toast(error.message); }
});
+59 -5
View File
@@ -3,6 +3,19 @@ function featureIcon(item, fallback) { return item.icon ? `<img src="${extendedE
const backupDialogTextFix = new MutationObserver(() => { const dialog = document.querySelector("#create-backup-dialog"); if (dialog) dialog.querySelectorAll("p,small").forEach(node => { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); });
backupDialogTextFix.observe(document.body, { childList:true, subtree:true });
function renderStreams() {
const list = document.querySelector("#stream-list"), empty = document.querySelector("#stream-empty");
if (!state.loaded) return;
empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0);
list.innerHTML = state.streams.map(item => {
const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled";
const upstream = item.enabled === false || item.upstream?.status === "unmonitored" ? "Monitoring paused" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`;
const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `<span class="chip">${value}</span>`).join("");
const toggle = `<button class="toggle ${item.enabled === false ? "" : "on"}" data-stream-action="toggle" aria-label="${item.enabled === false ? "Enable" : "Disable"} ${extendedEscape(item.name)}"><span></span></button>`;
return `<article class="site-card stream-card" data-stream-id="${item.id}" data-kind="stream"><div class="card-top"><div class="site-icon">${featureIcon(item,"SH")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Streaming host options" aria-expanded="false">•••</button><div class="menu"><button data-stream-action="edit">Edit streaming host</button><button data-stream-action="icon">Change icon</button><button data-stream-action="delete" class="danger-text">Delete streaming host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">Port ${item.port}</p><p class="gateway-address">→ ${extendedEscape(item.target)}</p><p class="upstream-copy ${item.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}${protocols}</div></div></article>`;
}).join("");
}
function renderRedirects() {
const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty");
// Keep the existing cards or empty state mounted while the shared refresh is pending.
@@ -34,8 +47,34 @@ function renderDefaultSettings() {
if (!document.querySelector("#default-site-help")) { const help = document.createElement("p"); help.id = "default-site-help"; help.className = "muted"; help.textContent = "The Default Site handles unknown HTTP hostnames. HTTPS requests still require a matching host and certificate."; form.prepend(help); }
for (const key of ["mode","title","message","redirectUrl","redirectCode","customHtml"]) if (form.elements[key] && value[key] !== undefined) form.elements[key].value = value[key];
form.elements.preservePath.checked = value.preservePath !== false;
renderDefaultSitePreview();
}
// Keep this HTML template in sync with the themed-page literal inside renderDefaultSiteHtml()
// in src/server.js -- duplicated here so the live preview below renders instantly, without a
// round trip to the server, on every keystroke. If the server template ever changes, this must
// be updated to match or the preview will silently drift from what visitors actually see.
function defaultSiteThemedHtml(title, message) {
const safeTitle = extendedEscape(title);
const safeMessage = extendedEscape(message);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"><title>${safeTitle}</title><style>:root{color-scheme:dark light;--bg:#08101d;--card:#101a2b;--line:#25344c;--text:#eef4ff;--muted:#95a4ba;--green:#62e6a7}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 0,#163829 0,transparent 42%),var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif}.card{width:min(620px,100%);padding:44px;border:1px solid var(--line);border-radius:22px;background:color-mix(in srgb,var(--card) 94%,transparent);box-shadow:0 28px 80px #0006}.mark{width:54px;height:54px;border-radius:15px;display:grid;place-items:center;background:#17352a;color:var(--green);font-weight:900}.eyebrow{margin:28px 0 10px;color:var(--green);font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;font-size:clamp(34px,7vw,56px);letter-spacing:-.05em;line-height:1.02}p{color:var(--muted);font-size:17px;line-height:1.65;margin:20px 0 0}.foot{padding-top:28px;margin-top:30px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}@media(prefers-color-scheme:light){:root{--bg:#f3f6fa;--card:#fff;--line:#d6dfeb;--text:#132033;--muted:#637188;--green:#138a5b}}</style></head><body><main class="card"><div class="mark">SG</div><div class="eyebrow">Site Gateway</div><h1>${safeTitle}</h1><p>${safeMessage}</p><div class="foot">Host. Proxy. Secure.</div></main></body></html>`;
}
function renderDefaultSitePreview() {
const form = document.querySelector("#default-site-form"), frame = document.querySelector("#default-site-preview-frame"), note = document.querySelector("#default-site-preview-note");
if (!form || !frame || !note) return;
const mode = form.elements.mode.value;
if (mode === "abort") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); note.textContent = "No page is shown for this mode \u2014 the connection is closed immediately."; return; }
if (mode === "redirect") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); const destination = form.elements.redirectUrl.value.trim(); note.textContent = destination ? `No page is shown for this mode \u2014 visitors are redirected to ${destination}.` : "No page is shown for this mode \u2014 enter a redirect destination above."; return; }
frame.closest(".default-site-preview-frame-wrap").classList.remove("hidden"); note.classList.add("hidden");
if (mode === "custom") { frame.srcdoc = form.elements.customHtml.value || "<p style=\"font:14px sans-serif;color:#95a4ba;padding:20px\">Enter Custom HTML above to preview it here.</p>"; return; }
const title = form.elements.title.value || (mode === "welcome" ? "Gateway ready" : "Route not found");
const message = form.elements.message.value || "The gateway is responding, but this address has not been configured.";
frame.srcdoc = defaultSiteThemedHtml(title, message);
}
document.querySelector("#default-site-form")?.addEventListener("input", renderDefaultSitePreview);
document.querySelector("#default-site-form")?.addEventListener("change", renderDefaultSitePreview);
function renderHealthSettings() {
if (!state.settings) return; const form = document.querySelector("#health-settings-form"), value = state.settings.certificateHealth || {};
for (const key of ["warningDays","criticalDays","staleMinutes"]) if (value[key] !== undefined) form.elements[key].value = value[key];
@@ -43,12 +82,27 @@ function renderHealthSettings() {
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div id="audit-list" class="dashboard-list event-list"><p class="quiet-state">Open this tab to load audit records.</p></div>'; users.parentElement.insertBefore(panel, users.nextElementSibling); const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `<div class="event-row"><span class="activity-mark ${item.status === "error" ? "bad" : ""}">${item.status === "error" ? "!" : "✓"}</span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join("") : '<p class="quiet-state">No matching audit records.</p>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
window.renderExtendedViews = function () { renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); };
window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); };
for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", `<option value="${hour}">${String(hour).padStart(2,"0")}:00</option>`);
document.querySelector("#stream-form").addEventListener("submit", async event => {
event.preventDefault(); const form = new FormData(event.target), body = { name: form.get("name"), port: Number(form.get("port")), target: form.get("target"), tcp: form.has("tcp"), udp: form.has("udp"), healthEnabled: form.has("healthEnabled") }; document.querySelector("#stream-error").textContent = "";
try { const id = event.target.dataset.editing; await api(id ? `/api/streams/${id}` : "/api/streams", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#stream-dialog").close(); await refresh(); toast(`Streaming host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#stream-error").textContent = error.message; }
});
document.querySelector("#stream-list").addEventListener("click", async event => {
const button = event.target.closest("[data-stream-action]"), card = button?.closest("[data-stream-id]"); if (!button || !card) return; const item = state.streams.find(value => value.id === card.dataset.streamId); if (!item) return;
try {
if (button.dataset.streamAction === "edit") { const form = document.querySelector("#stream-form"); form.reset(); form.dataset.editing = item.id; form.elements.name.value = item.name || ""; form.elements.port.value = item.port; form.elements.target.value = item.target || ""; form.elements.tcp.checked = item.tcp !== false; form.elements.udp.checked = Boolean(item.udp); form.elements.healthEnabled.checked = item.healthEnabled !== false; document.querySelector("#stream-title").textContent = "Edit streaming host"; document.querySelector("#stream-form .button.primary").textContent = "Save & apply"; document.querySelector("#stream-error").textContent = ""; document.querySelector("#stream-dialog").showModal(); return; }
if (button.dataset.streamAction === "icon") { card.classList.remove("menu-open"); return openIconPicker("streams", item.id); }
if (button.dataset.streamAction === "delete") { if (!confirm(`Delete streaming host “${item.name}”?`)) return; await api(`/api/streams/${item.id}`, { method: "DELETE" }); await refresh(); toast("Streaming host deleted."); return; }
await api(`/api/streams/${item.id}/toggle`, { method: "POST" }); await refresh(); toast("Streaming host updated.");
} catch (error) { toast(error.message); }
});
document.querySelector("#stream-list").addEventListener("click", event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-stream-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#stream-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); } });
document.querySelector("#redirect-form").addEventListener("submit", async event => {
event.preventDefault(); const form = new FormData(event.target), body = Object.fromEntries(form); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.preservePath = form.has("preservePath"); document.querySelector("#redirect-error").textContent = "";
try { const id = event.target.dataset.editing; await api(id ? `/api/redirects/${id}` : "/api/redirects", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#redirect-dialog").close(); await refresh(); toast(`Redirect Host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#redirect-error").textContent = error.message; }
@@ -70,7 +124,7 @@ document.querySelector("#redirect-list").addEventListener("click", async event =
try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); }
});
function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false) { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Access Lists</p><h2>${extendedEscape(title)}</h2></div></div><p class="muted">${copy}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button>${confirmLabel ? `<button value="confirm" class="button ${danger ? "danger" : "primary"}">${extendedEscape(confirmLabel)}</button>` : ""}</div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); }
function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false, eyebrow = "Access Lists") { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">${extendedEscape(eyebrow)}</p><h2>${extendedEscape(title)}</h2></div></div><p class="muted">${copy}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button>${confirmLabel ? `<button value="confirm" class="button ${danger ? "danger" : "primary"}">${extendedEscape(confirmLabel)}</button>` : ""}</div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); }
document.querySelector("#access-list").addEventListener("click", async event => {
if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-access-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#access-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
const button = event.target.closest("[data-access-action]"), row = button?.closest("[data-access-id]"); if (!button || !row) return; const item = state.accessLists.find(value => value.id === row.dataset.accessId); if (!item) return;
@@ -98,7 +152,7 @@ document.querySelector("#backup-upload").addEventListener("change", async event
document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } });
const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; });
document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"smooth", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0; for (const article of articles) { const match = !query || `${article.dataset.doc} ${article.textContent}`.toLowerCase().includes(query); article.classList.toggle("hidden", !match); if (match) visible++; } document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); });
document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"instant", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let topResult = null, topOrder = Infinity; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query), order = topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2; article.style.order = ""; if (match && order < topOrder) { topOrder = order; topResult = article; } }); articles.forEach(article => article.classList.toggle("hidden", Boolean(query) && article !== topResult)); document.querySelector("#doc-empty").classList.toggle("hidden", Boolean(!query || topResult)); });
document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open"));
document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); }));
@@ -115,7 +169,7 @@ function renderGroups() { const tabs = document.querySelector(".admin-tabs"); co
function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Edit group</h2></div><button type="button" class="icon-button close-group-dialog">×</button></div><label>Group name<input name="name" required maxlength="80"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Save group</button></div></form>'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true);
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div><button type="button" class="icon-button close-group-dialog">×</button></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; try { const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:button.classList.contains("toggle") ? !button.classList.contains("on") : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { toast(error.message); } });
document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true);
document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });
+163 -67
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

+47 -23
View File
File diff suppressed because one or more lines are too long
+393 -25
View File
@@ -5,13 +5,16 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import http from "node:http";
import net from "node:net";
import dgram from "node:dgram";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import AdmZip from "adm-zip";
import express from "express";
import multer from "multer";
import QRCode from "qrcode";
import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js";
import { generateTotpSecret, verifyTotp, otpauthUri, generateRecoveryCodes } from "./totp.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageMetadata = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
@@ -44,13 +47,16 @@ const adminPassword = process.env.ADMIN_PASSWORD || "change-this-password";
const sessionSecret = process.env.SESSION_SECRET || crypto.createHash("sha256").update(`${adminUser}:${adminPassword}`).digest("hex");
const scheduledBackupPassword = process.env.BACKUP_PASSWORD || "";
const activeServers = new Map();
const activeStreams = new Map();
let sites = [];
let proxies = [];
let users = [];
let redirects = [];
let streams = [];
let accessLists = [];
let groups = [];
let settings = {};
let publicIpState = { address: null, checkedAt: null, error: null };
let gatewayError = null;
let lastGatewayReload = null;
let caddyVersion = "Unknown";
@@ -107,8 +113,8 @@ async function passwordMatches(password, record) {
}
function publicUser(user) {
const { password, sessionVersion, ...safe } = user;
return safe;
const { password, sessionVersion, mfaSecret, mfaPendingSecret, mfaRecoveryCodes, ...safe } = user;
return { ...safe, mfaEnabled: Boolean(user.mfaEnabled) };
}
function activeAdministrators() {
@@ -141,6 +147,7 @@ const saveProxies = async () => storage.saveCollection("proxies", proxies);
const saveUsers = async () => storage.saveCollection("users", users);
const saveGroups = async () => storage.saveCollection("groups", groups);
const saveRedirects = async () => storage.saveCollection("redirects", redirects);
const saveStreams = async () => storage.saveCollection("streams", streams);
const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists);
const saveSettings = async () => storage.saveSettings(settings);
@@ -188,9 +195,12 @@ async function loadSites() {
for (const user of users) {
if (user.setupRequired === undefined) { user.setupRequired = false; usersChanged = true; }
if (!user.sessionVersion) { user.sessionVersion = crypto.randomBytes(16).toString("hex"); usersChanged = true; }
if (user.mfaEnabled === undefined) { user.mfaEnabled = false; usersChanged = true; }
if (!Array.isArray(user.mfaRecoveryCodes)) { user.mfaRecoveryCodes = []; usersChanged = true; }
}
if (usersChanged) await saveUsers();
redirects = storage.loadCollection("redirects");
streams = storage.loadCollection("streams").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") }));
accessLists = storage.loadCollection("access_lists");
groups = storage.loadCollection("groups");
const defaultSettings = {
@@ -232,6 +242,27 @@ function validateTarget(value) {
}
}
function validateStreamPort(value) {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Object.assign(new Error("Incoming port must be between 1 and 65535."), { status: 400 });
return port;
}
function validateStreamHostPort(value) {
const raw = String(value || "").trim();
const match = raw.match(/^\[?([^\s\]]+)\]?:(\d{1,5})$/);
if (!match) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
const port = Number(match[2]);
if (!match[1] || port < 1 || port > 65535) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
return `${match[1]}:${port}`;
}
function streamPortConflict(port, exceptId) {
if (port === adminPort || port === 80 || port === 443 || (port >= minPort && port <= maxPort)) return "That port is already reserved by the gateway.";
if (streams.some(item => item.port === port && item.id !== exceptId)) return "That port is already used by another streaming host.";
return null;
}
function cleanHeaders(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() }))
@@ -259,9 +290,11 @@ function applyAdvancedSettings(item, body) {
if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 });
item.upstreams = body.upstreams.map(validateTarget);
}
if (body.lbPolicy !== undefined) item.lbPolicy = ["random", "round_robin", "least_conn", "ip_hash"].includes(body.lbPolicy) ? body.lbPolicy : "random";
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
if (body.blockCommonExploits !== undefined) item.blockCommonExploits = Boolean(body.blockCommonExploits);
if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
@@ -295,7 +328,12 @@ function caddySiteAddress(item) {
}
function caddyQuote(value) {
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", " ")}"`;
// Caddy's Caddyfile lexer only special-cases \" inside a quoted string — it does NOT
// collapse \\ into a single backslash (confirmed in caddyconfig/caddyfile/lexer.go: "all is
// literal in quoted area, so only escape quotes"). Doubling backslashes here, as this used to,
// corrupts any value that legitimately contains one (e.g. a regex like eval\( becomes eval\\(,
// which Caddy then reads as an escaped backslash followed by an unclosed real group).
return `"${String(value).replaceAll('"', '\\"').replaceAll("\n", " ")}"`;
}
function accessDirectives(accessListId) {
@@ -312,8 +350,19 @@ function accessDirectives(accessListId) {
return output;
}
// Static, general-purpose ruleset for the "Block common exploits" toggle — not a full WAF. Rejects
// requests whose path matches common exploit-probe patterns before they reach the upstream: directory
// traversal, WordPress/PHP admin and scanner paths, dotfile exposure attempts, and SQL-injection-style
// query strings. One named matcher + one respond directive per host, so it's cheap to add or remove.
const COMMON_EXPLOIT_PATTERN = String.raw`(?i)(\.\./|\.\.\\|/etc/passwd|/wp-login\.php|/wp-admin(?:/|$)|/xmlrpc\.php|/\.env(?:$|\?)|/\.git/|/\.aws/|/vendor/phpunit|/phpunit(?:/|$)|eval\(|base64_decode\(|union(?:\s|%20|\+)+select|<script)`;
function exploitBlockDirectives(id) {
return [` @blocked-exploit-${id} {`, ` path_regexp ${caddyQuote(COMMON_EXPLOIT_PATTERN)}`, " }", ` respond @blocked-exploit-${id} 403`];
}
function commonHostDirectives(item) {
const output = [...accessDirectives(item.accessListId)];
if (item.blockCommonExploits) output.push(...exploitBlockDirectives(item.id));
if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip");
for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`);
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
@@ -325,6 +374,7 @@ function commonHostDirectives(item) {
function proxyBlock(target, item, indent = " ") {
const targets = Array.isArray(item.upstreams) && item.upstreams.length ? item.upstreams : [target];
const output = [`${indent}reverse_proxy ${targets.join(" ")} {`];
if (targets.length > 1) { const policy = ["round_robin", "least_conn", "ip_hash"].includes(item.lbPolicy) ? item.lbPolicy : "random"; output.push(`${indent} lb_policy ${policy}`); }
const timeout = Math.min(Math.max(Number(item.healthTimeoutSeconds) || 4, 1), 60);
const httpsUpstream = targets.length > 0 && targets.every(value => /^https:\/\//i.test(String(value).trim()));
if (httpsUpstream && (item.upstreamTlsServerName || item.upstreamTlsInsecure)) output.push(`${indent} transport http {`, ...(item.upstreamTlsServerName ? [`${indent} tls_server_name ${item.upstreamTlsServerName}`] : []), ...(item.upstreamTlsInsecure ? [`${indent} tls_insecure_skip_verify`] : []), `${indent} response_header_timeout ${timeout}s`, `${indent} }`);
@@ -354,7 +404,7 @@ function renderCaddyfile() {
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
lines.push("}");
for (const site of sites.filter(item => item.enabled && item.domain)) {
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) {
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
lines.push("}");
}
@@ -398,7 +448,7 @@ async function syncCaddy() {
}
if (previousDefaultPage !== null) await fsp.writeFile(path.join(defaultSiteDir, "index.html"), previousDefaultPage);
try {
sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings;
sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); streams = storage.loadCollection("streams"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings;
} catch { /* Startup may not have completed database initialization yet. */ }
gatewayError = rollbackSucceeded ? null : rejectedReason;
const friendly = /upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(rejectedReason) ? "This host forwards to HTTP, but Ignore upstream TLS certificate errors is enabled. Turn that option off or change the upstream to HTTPS." : /upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(rejectedReason) ? "This host forwards to HTTPS, but its upstream transport is configured for plain HTTP. Use HTTPS transport settings or change the upstream to HTTP." : /duplicate.*address|already.*site address/i.test(rejectedReason) ? "This hostname or address is already used by another host. Choose a unique hostname and port." : /dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(rejectedReason) ? "The upstream address could not be reached or is invalid. Check the hostname, IP address, and port." : /invalid hostname|host name.*invalid|malformed.*host/i.test(rejectedReason) ? "The hostname is not valid. Use a valid domain name without a protocol or path." : /unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(rejectedReason) ? "The gateway configuration contains an unsupported or malformed directive. Check the selected host settings." : /certificate|tls.*(config|handshake)|no certificate/i.test(rejectedReason) ? "The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings." : "The gateway rejected this configuration. Check the host, upstream address, and TLS settings.";
@@ -423,6 +473,10 @@ function publicProxy(proxy, includeAdvanced = false) {
return { ...safe, domains: normalizeDomains(proxy.domain, proxy.domains), certificatePath: certificatePath ? "installed" : null, hasCustomCertificate: Boolean(certificatePath && keyPath), status: proxy.enabled ? (gatewayError ? "error" : "running") : "disabled", upstream: upstreamHealth.get(proxy.id) || null };
}
function publicStream(stream) {
return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null };
}
async function walkFiles(directory) {
const output = [];
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
@@ -524,7 +578,7 @@ async function checkProxy(proxy) {
}
async function checkAllProxies() {
await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled }))]);
await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled })), ...streams.map(checkStream)]);
return proxies.map(publicProxy);
}
@@ -581,6 +635,18 @@ function tcpProbe(port, timeoutMs = 1000) {
});
}
function tcpProbeHost(host, port, timeoutMs = 4000) {
return new Promise(resolve => {
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return resolve(false);
const socket = net.createConnection({ host, port });
const finish = result => { socket.destroy(); resolve(result); };
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function stableProbe(name, responding) {
if (responding) { probeFailures[name] = 0; return { status: "ready", healthy: true, responding: true }; }
probeFailures[name] += 1;
@@ -628,6 +694,8 @@ async function cacheIcon(slug) {
async function dashboardSnapshot() {
const hosted = sites.map(publicSite);
const proxyHosts = proxies.map(publicProxy);
const enabledStreams = streams.filter(item => item.enabled !== false);
const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length };
const certificates = await certificateInventory();
const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length;
const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([
@@ -665,6 +733,8 @@ async function dashboardSnapshot() {
tlsDomains,
certificates: certificates.summary,
upstreams: { total: proxyHosts.filter(item => item.enabled).length, healthy: proxyHosts.filter(item => item.upstream?.status === "healthy").length, unhealthy: proxyHosts.filter(item => item.upstream?.status === "unhealthy").length },
streamingPorts,
throughput: { liveRequests: storage.performanceLiveCount(60) },
attention,
system: {
uptimeSeconds: Math.floor(process.uptime()),
@@ -678,7 +748,10 @@ async function dashboardSnapshot() {
databaseEngine: "SQLite",
databaseStatus: databaseIntegrity.length === 1 && databaseIntegrity[0] === "ok" ? "Healthy" : "Needs attention",
databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0,
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }]
publicIp: publicIpState.address,
publicIpCheckedAt: publicIpState.checkedAt,
publicIpError: publicIpState.error,
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }]
},
activity: recentActivity
};
@@ -712,6 +785,90 @@ async function restartSite(site) {
if (site.enabled) await startSite(site);
}
// Streaming hosts relay raw TCP/UDP on a specific port straight to a host:port target — no domain, no HTTP,
// no Caddy involvement. This is the same pattern as startSite()/stopSite() above: a dedicated listener Site
// Gateway owns directly, just for a plain socket instead of an HTTP server.
async function startStream(stream) {
if (stream.enabled === false || activeStreams.has(stream.id)) return;
const [targetHost, targetPortRaw] = String(stream.target || "").split(":");
const targetPort = Number(targetPortRaw);
const handle = { tcpServer: null, udpSocket: null, udpSessions: new Map() };
try {
if (stream.tcp !== false) {
const tcpServer = net.createServer(socket => {
const upstream = net.createConnection({ host: targetHost, port: targetPort });
const destroyBoth = () => { socket.destroy(); upstream.destroy(); };
socket.on("error", destroyBoth); upstream.on("error", destroyBoth);
socket.on("close", () => upstream.destroy()); upstream.on("close", () => socket.destroy());
socket.pipe(upstream); upstream.pipe(socket);
});
await new Promise((resolve, reject) => { tcpServer.once("error", reject); tcpServer.listen(stream.port, "0.0.0.0", resolve); });
tcpServer.on("error", error => console.warn(`Streaming host “${stream.name}” TCP error:`, error.message));
handle.tcpServer = tcpServer;
}
if (stream.udp) {
const udpSocket = dgram.createSocket("udp4");
udpSocket.on("message", (message, rinfo) => {
const key = `${rinfo.address}:${rinfo.port}`;
let session = handle.udpSessions.get(key);
if (!session) {
const outbound = dgram.createSocket("udp4");
session = { outbound, timer: null, connected: false, pending: [] };
outbound.on("message", reply => { try { udpSocket.send(reply, rinfo.port, rinfo.address); } catch { /* client socket may already be gone */ } });
outbound.on("error", () => {});
// connect() is asynchronous — sending before it completes silently drops the datagram, which would
// lose the first packet of every new UDP session. Queue until the callback confirms it's connected.
outbound.connect(targetPort, targetHost, () => { session.connected = true; for (const buffered of session.pending.splice(0)) { try { outbound.send(buffered); } catch { /* upstream may be unreachable */ } } });
handle.udpSessions.set(key, session);
}
clearTimeout(session.timer);
session.timer = setTimeout(() => { session.outbound.close(); handle.udpSessions.delete(key); }, 60000).unref();
if (session.connected) { try { session.outbound.send(message); } catch { /* upstream may be unreachable; drop this datagram */ } }
else session.pending.push(message);
});
await new Promise((resolve, reject) => { udpSocket.once("error", reject); udpSocket.bind(stream.port, "0.0.0.0", resolve); });
udpSocket.on("error", error => console.warn(`Streaming host “${stream.name}” UDP error:`, error.message));
handle.udpSocket = udpSocket;
}
} catch (error) {
if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve));
if (handle.udpSocket) handle.udpSocket.close();
throw error;
}
activeStreams.set(stream.id, handle);
console.log(`Streaming “${stream.name}” on port ${stream.port}`);
}
async function stopStream(id) {
const handle = activeStreams.get(id);
if (!handle) return;
if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve));
if (handle.udpSocket) {
for (const session of handle.udpSessions.values()) { clearTimeout(session.timer); session.outbound.close(); }
handle.udpSocket.close();
}
activeStreams.delete(id);
}
async function restartStream(stream) {
await stopStream(stream.id);
if (stream.enabled !== false) await startStream(stream);
}
async function checkStream(stream) {
if (stream.enabled === false) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(stream.id, result); return result; }
if (stream.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(stream.id, result); return result; }
const started = performance.now();
const [targetHost, targetPortRaw] = String(stream.target || "").split(":");
const healthy = await tcpProbeHost(targetHost, Number(targetPortRaw), 4000);
const responseMs = Math.round(performance.now() - started);
const result = { status: healthy ? "healthy" : "unhealthy", responseMs, checkedAt: new Date().toISOString(), error: healthy ? null : `Could not open a TCP connection to ${stream.target}` };
const previous = upstreamHealth.get(stream.id);
result.history = [{ status: result.status, responseMs, checkedAt: result.checkedAt }, ...(previous?.history || [])].slice(0, 7);
upstreamHealth.set(stream.id, result);
return result;
}
function validatePort(port, exceptId) {
if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`;
if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned.";
@@ -754,7 +911,7 @@ async function installUpload(site, file) {
}
}
const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings };
const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "streams.json": () => streams, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings };
async function protectBackup(buffer, password) {
if (!password) return buffer;
@@ -832,7 +989,7 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
await fsp.copyFile(restoredDatabase, activeDatabasePath); storage = await openStorage(dataDir, backupsDir);
} else {
const legacyRoot = fs.existsSync(path.join(staging, "portable-json")) ? path.join(staging, "portable-json") : fs.existsSync(path.join(staging, "legacy-json")) ? path.join(staging, "legacy-json") : path.join(staging, "config");
storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []);
storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []); storage.saveCollection("streams", []);
for (const [name, kind] of Object.entries({ "access-lists.json":"access_lists", "sites.json":"sites", "proxies.json":"proxies", "redirects.json":"redirects", "users.json":"users" })) { const candidate = path.join(legacyRoot, name); if (fs.existsSync(candidate)) storage.saveCollection(kind, JSON.parse(await fsp.readFile(candidate, "utf8"))); }
const settingsCandidate = path.join(legacyRoot, "settings.json"); if (fs.existsSync(settingsCandidate)) storage.saveSettings(JSON.parse(await fsp.readFile(settingsCandidate, "utf8")));
}
@@ -843,9 +1000,10 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
if (manifest.type === "complete" && fs.existsSync(path.join(staging, "custom-certificates"))) {
await fsp.mkdir(customCertificatesDir, { recursive: true }); await fsp.cp(path.join(staging, "custom-certificates"), customCertificatesDir, { recursive: true });
}
await Promise.all([...activeServers.keys()].map(stopSite)); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites();
await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites();
if (manifest.type === "complete") for (const site of sites) { const contentRoot = path.join(sitesDir, site.id); if (!fs.existsSync(path.join(contentRoot, "index.html"))) throw new Error(`Restored hosted site “${site.name || site.id}” is missing index.html.`); }
for (const site of sites.filter(item => item.enabled)) await startSite(site);
for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } }
await syncCaddy(); recordActivity(`Backup ${filename} restored.`);
} catch (error) {
if (safetyBackup) {
@@ -867,6 +1025,9 @@ try {
for (const site of sites.filter(item => item.enabled)) {
try { await startSite(site); } catch (error) { console.error(`Could not start ${site.name}:`, error.message); }
}
for (const stream of streams.filter(item => item.enabled !== false)) {
try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); }
}
for (let attempt = 0; attempt < 10; attempt++) {
try { await syncCaddy(); break; }
catch (error) {
@@ -884,7 +1045,8 @@ app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get(["/", "/index.html"], (req, res) => {
const html = fs.readFileSync(path.join(publicDir, "index.html"), "utf8")
.replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`);
.replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`)
.replace(/\/styles\.css\?v=[^"']+/g, `/styles.css?v=${appVersion}`);
res.type("html").send(html);
});
app.use(express.static(publicDir));
@@ -894,11 +1056,34 @@ app.get("/api/session", (req, res) => {
const user = sessionUser(req);
res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null });
});
function checkLoginRateLimit(key) {
const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 };
if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; }
return attempt;
}
function issueSessionCookie(res, user) {
if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex");
const expires = String(Date.now() + 12 * 60 * 60 * 1000);
const value = `${user.id}.${expires}.${user.sessionVersion}`;
res.setHeader("Set-Cookie", [`webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`, "pending_mfa=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"]);
}
function issuePendingMfaCookie(res, user) {
const expires = String(Date.now() + 5 * 60 * 1000);
const value = `${user.id}.${expires}.mfa`;
res.setHeader("Set-Cookie", `pending_mfa=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=300`);
}
function pendingMfaUser(req) {
const token = cookieMap(req.headers.cookie).pending_mfa;
if (!token) return null;
const [userId, expires, marker, signature] = token.split(".");
const user = users.find(item => item.id === userId && item.status === "active");
if (!user || marker !== "mfa" || !expires || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${userId}.${expires}.${marker}`))) return null;
return user;
}
app.post("/api/login", async (req, res, next) => {
try {
const key = req.ip || req.socket.remoteAddress || "unknown";
const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 };
if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; }
const attempt = checkLoginRateLimit(key);
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
const username = String(req.body.username || "").trim().toLowerCase();
const user = users.find(item => item.username === username);
@@ -907,11 +1092,36 @@ app.post("/api/login", async (req, res, next) => {
return res.status(401).json({ error: "Incorrect username or password." });
}
loginAttempts.delete(key);
if (user.mfaEnabled) { issuePendingMfaCookie(res, user); return res.json({ mfaRequired: true }); }
user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers();
if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex");
const expires = String(Date.now() + 12 * 60 * 60 * 1000);
const value = `${user.id}.${expires}.${user.sessionVersion}`;
res.setHeader("Set-Cookie", `webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`);
issueSessionCookie(res, user);
res.json({ ok: true, user: publicUser(user) });
} catch (error) { next(error); }
});
app.post("/api/login/mfa", async (req, res, next) => {
try {
const key = req.ip || req.socket.remoteAddress || "unknown";
const attempt = checkLoginRateLimit(key);
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
const user = pendingMfaUser(req);
if (!user || !user.mfaEnabled) { attempt.count += 1; loginAttempts.set(key, attempt); return res.status(401).json({ error: "Your sign-in session expired. Please sign in again." }); }
const code = String(req.body.code || "").trim();
let matchedRecoveryCode = null;
const isValidTotp = verifyTotp(user.mfaSecret, code);
if (!isValidTotp) {
for (const entry of user.mfaRecoveryCodes || []) {
if (entry.usedAt) continue;
if (await passwordMatches(code, entry.hash)) { matchedRecoveryCode = entry; break; }
}
}
if (!isValidTotp && !matchedRecoveryCode) {
attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed two-factor code for “${user.username}”.`, "error");
return res.status(401).json({ error: "That code didn't match. Try again." });
}
loginAttempts.delete(key);
if (matchedRecoveryCode) { matchedRecoveryCode.usedAt = new Date().toISOString(); recordActivity(`User “${user.username}” signed in using a two-factor recovery code.`); }
user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers();
issueSessionCookie(res, user);
res.json({ ok: true, user: publicUser(user) });
} catch (error) { next(error); }
});
@@ -973,7 +1183,65 @@ app.post("/api/setup/admin", async (req, res, next) => {
} catch (error) { next(error); }
});
app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); });
app.use("/api", (req, res, next) => { if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); });
app.use("/api", (req, res, next) => { if (req.path.startsWith("/account/")) return next(); if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); });
app.post("/api/account/password", async (req, res, next) => {
try {
const currentPassword = String(req.body.currentPassword || "");
const newPassword = String(req.body.newPassword || "");
if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." });
req.user.password = await passwordRecord(newPassword);
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`);
issueSessionCookie(res, req.user);
res.json({ ok: true });
} catch (error) { next(error); }
});
app.post("/api/account/mfa/setup", async (req, res, next) => {
try {
if (req.user.mfaEnabled) return res.status(409).json({ error: "Two-factor authentication is already enabled. Disable it first to start over." });
const secret = generateTotpSecret();
req.user.mfaPendingSecret = secret;
await saveUsers();
const uri = otpauthUri({ secret, username: req.user.username });
const qrSvg = await QRCode.toString(uri, { type: "svg", margin: 1, width: 220 });
res.json({ secret, otpauthUri: uri, qrSvg });
} catch (error) { next(error); }
});
app.post("/api/account/mfa/confirm", async (req, res, next) => {
try {
if (!req.user.mfaPendingSecret) return res.status(400).json({ error: "Start two-factor setup before confirming a code." });
if (!verifyTotp(req.user.mfaPendingSecret, req.body.code)) return res.status(400).json({ error: "That code didn't match. Try again." });
req.user.mfaSecret = req.user.mfaPendingSecret;
req.user.mfaPendingSecret = null;
req.user.mfaEnabled = true;
const codes = generateRecoveryCodes(10);
req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null })));
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” enabled two-factor authentication.`);
res.json({ ok: true, recoveryCodes: codes });
} catch (error) { next(error); }
});
app.post("/api/account/mfa/disable", async (req, res, next) => {
try {
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = [];
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning");
res.json({ ok: true });
} catch (error) { next(error); }
});
app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
try {
if (!req.user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn't enabled." });
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
const codes = generateRecoveryCodes(10);
req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null })));
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” regenerated two-factor recovery codes.`);
res.json({ ok: true, recoveryCodes: codes });
} catch (error) { next(error); }
});
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } }));
app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." }));
app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." }));
@@ -1079,6 +1347,22 @@ app.get("/api/logs", async (req, res, next) => {
res.json({ entries: storage.listAccessEvents(limit, host), hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort(), activity: recentActivity });
} catch (error) { next(error); }
});
app.get("/api/performance", (req, res, next) => {
try {
const host = normalizeDomain(req.query.host);
const hours = Math.min(Math.max(Number.parseInt(req.query.hours, 10) || 6, 1), 168);
const bucketMinutes = hours > 24 ? 60 : 15;
const breakdownByHost = new Map();
for (const row of storage.performanceErrorBreakdown()) { if (!breakdownByHost.has(row.host)) breakdownByHost.set(row.host, []); breakdownByHost.get(row.host).push({ status: row.status, count: row.count }); }
res.json({
checkedAt: new Date().toISOString(),
liveRequests: storage.performanceLiveCount(60),
routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, errorBreakdown: (breakdownByHost.get(row.host) || []).slice(0, 3) })),
trend: storage.performanceTrend(host, hours, bucketMinutes),
hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort()
});
} catch (error) { next(error); }
});
app.get("/api/icons/search", async (req, res, next) => {
try {
const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80);
@@ -1099,7 +1383,7 @@ function entryLabel(item) {
}
app.put("/api/:kind/:id/icon", async (req, res, next) => {
try {
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id);
if (!item) return res.status(404).json({ error: "Entry not found." });
@@ -1107,7 +1391,7 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
const url = String(req.body.url || "").trim();
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
item.iconSlug = null; item.icon = url;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
recordActivity(`Icon URL updated for “${entryLabel(item)}”.`);
return res.json(item);
}
@@ -1115,14 +1399,14 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
const icon = slug ? await cacheIcon(slug) : null;
item.iconSlug = slug || null;
item.icon = icon;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else await saveAccessLists();
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else await saveAccessLists();
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(item)}”.`);
res.json(item);
} catch (error) { next(error); }
});
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
try {
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id);
if (!item) return res.status(404).json({ error: "Entry not found." });
@@ -1132,7 +1416,7 @@ app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next
const filename = `${req.params.kind}-${item.id}.${extension}`;
await fsp.rename(req.file.path, path.join(iconsDir, filename));
item.iconSlug = null; item.icon = `/site-icons/${filename}`;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
recordActivity(`Custom icon uploaded for “${entryLabel(item)}”.`);
res.json(item);
} catch (error) { next(error); }
@@ -1277,6 +1561,10 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
if (req.body.tls !== undefined) proxy.tls = req.body.tls === "custom" && proxy.certificatePath && proxy.keyPath ? "custom" : ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : proxy.tls;
if (req.body.hsts !== undefined) proxy.hsts = req.body.hsts === true;
applyAdvancedSettings(proxy, req.body);
// Mirror the Hosted Site PATCH handler: react immediately instead of waiting on the 60s
// background checkAllProxies() timer or a page refresh to pick up a Monitor toggle/edit.
if (proxy.healthEnabled === false) upstreamHealth.set(proxy.id, { status: "unmonitored", checkedAt: null, history: [] });
else { upstreamHealth.set(proxy.id, { status: "pending", checkedAt: null, history: [] }); checkProxy(proxy).catch(error => console.warn("Proxy health check failed:", error.message)); }
await syncCaddy();
await pruneOrphanedCertificates(previousDomains);
await saveProxies();
@@ -1420,6 +1708,70 @@ app.delete("/api/redirects/:id", async (req, res, next) => {
try { const index = redirects.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Redirect Host not found." }); const [item] = redirects.splice(index, 1); await syncCaddy(); await pruneOrphanedCertificates(normalizeDomains(item.domain, item.domains)); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); }
});
app.get("/api/streams", (req, res) => res.json(streams.map(publicStream)));
app.post("/api/streams", async (req, res, next) => {
try {
const name = String(req.body.name || "").trim();
if (!name) return res.status(400).json({ error: "Name is required." });
const port = validateStreamPort(req.body.port);
const portError = streamPortConflict(port); if (portError) return res.status(400).json({ error: portError });
const target = validateStreamHostPort(req.body.target);
const tcp = req.body.tcp !== false, udp = req.body.udp === true;
if (!tcp && !udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." });
const stream = { id: `stream-${crypto.randomBytes(4).toString("hex")}`, name, port, target, tcp, udp, healthEnabled: req.body.healthEnabled !== false, enabled: true, createdAt: new Date().toISOString() };
try { await startStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${port}: ${error.message}` }); }
streams.push(stream);
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
await saveStreams();
recordActivity(`Streaming host “${stream.name}” created.`);
res.status(201).json(publicStream(stream));
} catch (error) { next(error); }
});
app.patch("/api/streams/:id", async (req, res, next) => {
try {
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
const next_ = { ...stream };
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
if (req.body.target !== undefined) next_.target = validateStreamHostPort(req.body.target);
if (req.body.tcp !== undefined) next_.tcp = Boolean(req.body.tcp);
if (req.body.udp !== undefined) next_.udp = Boolean(req.body.udp);
if (!next_.tcp && !next_.udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." });
if (req.body.healthEnabled !== undefined) next_.healthEnabled = req.body.healthEnabled === true || (typeof req.body.healthEnabled === "string" && req.body.healthEnabled.toLowerCase() === "true");
if (req.body.enabled !== undefined) next_.enabled = Boolean(req.body.enabled);
const portOrProtocolChanged = next_.port !== stream.port || next_.target !== stream.target || next_.tcp !== stream.tcp || next_.udp !== stream.udp || next_.enabled !== stream.enabled;
Object.assign(stream, next_);
if (portOrProtocolChanged) { try { await restartStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); } }
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
await saveStreams();
recordActivity(`Streaming host “${stream.name}” updated.`);
res.json(publicStream(stream));
} catch (error) { next(error); }
});
app.post("/api/streams/:id/toggle", async (req, res, next) => {
try {
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
stream.enabled = !stream.enabled;
try { await restartStream(stream); } catch (error) { stream.enabled = !stream.enabled; return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); }
if (stream.enabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
await saveStreams();
recordActivity(`Streaming host “${stream.name}${stream.enabled ? "enabled" : "disabled"}.`);
res.json(publicStream(stream));
} catch (error) { next(error); }
});
app.delete("/api/streams/:id", async (req, res, next) => {
try {
const index = streams.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Streaming host not found." });
const [item] = streams.splice(index, 1);
await stopStream(item.id); upstreamHealth.delete(item.id);
await saveStreams();
recordActivity(`Streaming host “${item.name}” deleted.`);
res.status(204).end();
} catch (error) { next(error); }
});
app.patch("/api/settings", async (req, res, next) => {
try {
if (req.body.defaultSite) {
@@ -1444,7 +1796,7 @@ app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } });
app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"configuration", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } });
app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } });
app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } });
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." }));
app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
app.post("/api/backups", async (req, res, next) => {
@@ -1469,7 +1821,7 @@ app.delete("/api/backups/:filename", async (req, res, next) => {
try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); }
});
function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); }
const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|access-lists)(\/|$)/i;
const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|streams|access-lists)(\/|$)/i;
app.use((error, req, res, next) => {
console.error(error);
const rawMessage = error.message || "Something went wrong.";
@@ -1512,8 +1864,24 @@ setInterval(() => runScheduledPruning(), 15 * 60000).unref();
setTimeout(() => importAccessLogsToSqlite(), 8000).unref();
setInterval(() => importAccessLogsToSqlite(), 30000).unref();
async function checkPublicIp() {
try {
const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(6000), headers: { "user-agent": "Site-Gateway-DDNS-Check/1.0" } });
if (!response.ok) throw new Error(`IP lookup returned HTTP ${response.status}.`);
const body = await response.json();
const address = String(body.ip || "").trim();
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(address) && !address.includes(":")) throw new Error("IP lookup returned an unexpected value.");
const changed = publicIpState.address && publicIpState.address !== address;
publicIpState = { address, checkedAt: new Date().toISOString(), error: null };
if (changed) recordActivity(`Public IP address changed to ${address}.`);
} catch (error) { publicIpState = { ...publicIpState, checkedAt: new Date().toISOString(), error: error.message }; }
}
setTimeout(() => checkPublicIp(), 4000).unref();
setInterval(() => checkPublicIp(), 60 * 60000).unref();
async function shutdown() {
await Promise.all([...activeServers.keys()].map(stopSite));
await Promise.all([...activeStreams.keys()].map(stopStream));
try { storage?.close(); } catch { /* Database may already be closed during restore. */ }
process.exit(0);
}
+41 -4
View File
@@ -6,9 +6,9 @@ import { DatabaseSync } from "node:sqlite";
import AdmZip from "adm-zip";
export const LOCAL_INSTANCE_ID = "local";
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "access_lists", "users", "groups"];
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"];
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", streams: "stream_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
function now() { return new Date().toISOString(); }
@@ -50,12 +50,14 @@ export async function openStorage(dataDir, backupsDir) {
CREATE TABLE IF NOT EXISTS hosted_sites (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS proxy_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS redirect_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS stream_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS access_lists (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS groups (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS hosted_sites_instance ON hosted_sites(instance_id);
CREATE INDEX IF NOT EXISTS proxy_hosts_instance ON proxy_hosts(instance_id);
CREATE INDEX IF NOT EXISTS redirect_hosts_instance ON redirect_hosts(instance_id);
CREATE INDEX IF NOT EXISTS stream_hosts_instance ON stream_hosts(instance_id);
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id);
CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id);
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
@@ -105,6 +107,41 @@ export async function openStorage(dataDir, backupsDir) {
function listActivity(limit = 100, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT message,status,category,created_at AS at FROM activity_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 100, 500))); }
function recordAccessEvents(events, instanceId = LOCAL_INSTANCE_ID) { const insert = db.prepare("INSERT OR IGNORE INTO access_events(instance_id,at,host,method,uri,status,size,duration_ms,remote_ip,source) VALUES(?,?,?,?,?,?,?,?,?,?)"); transaction(() => { for (const event of events) insert.run(instanceId, event.at || null, event.host || null, event.method || null, event.uri || null, event.status ?? null, event.size ?? null, event.durationMs ?? null, event.remoteIp || null, event.source); }); }
function listAccessEvents(limit = 100, host = "", instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT at,host,method,uri,status,size,duration_ms AS durationMs,remote_ip AS remoteIp FROM access_events WHERE instance_id=? AND (?='' OR host=?) ORDER BY id DESC LIMIT ?").all(instanceId, host, host, Math.max(1, Math.min(Number(limit) || 100, 500))); return rows; }
function performanceLiveCount(windowSeconds = 60, instanceId = LOCAL_INSTANCE_ID) { const cutoff = new Date(Date.now() - Math.max(5, Number(windowSeconds) || 60) * 1000).toISOString(); return db.prepare("SELECT COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=?").get(instanceId, cutoff).count; }
function performanceRoutes(instanceId = LOCAL_INSTANCE_ID) {
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
return db.prepare(`
SELECT host,
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
COUNT(*) AS dayRequests,
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
AVG(duration_ms) AS dayAvgMs
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
GROUP BY host ORDER BY dayRequests DESC
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
}
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
return db.prepare(`
SELECT host, status, COUNT(*) AS count
FROM access_events WHERE instance_id=? AND at>=? AND status>=400 AND host IS NOT NULL AND host!=''
GROUP BY host, status ORDER BY count DESC
`).all(instanceId, dayCutoff);
}
function performanceTrend(host = "", hours = 6, bucketMinutes = 15, instanceId = LOCAL_INSTANCE_ID) {
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
const cutoff = new Date(Date.now() - windowMs).toISOString();
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const buckets = new Map();
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
const points = [];
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
return points;
}
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
@@ -133,5 +170,5 @@ export async function openStorage(dataDir, backupsDir) {
}
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, close: () => db.close() };
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
}
+74
View File
@@ -0,0 +1,74 @@
import crypto from "node:crypto";
// Minimal RFC 4648 base32 (no padding), and RFC 6238 TOTP on top of RFC 4226 HOTP.
// Implemented against Node's built-in crypto only — no third-party dependency.
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buffer) {
let bits = "";
for (const byte of buffer) bits += byte.toString(2).padStart(8, "0");
let output = "";
for (let i = 0; i + 5 <= bits.length; i += 5) output += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)];
const remainder = bits.length % 5;
if (remainder) output += BASE32_ALPHABET[parseInt(bits.slice(bits.length - remainder).padEnd(5, "0"), 2)];
return output;
}
export function base32Decode(value) {
const cleaned = String(value || "").toUpperCase().replace(/[^A-Z2-7]/g, "");
let bits = "";
for (const char of cleaned) {
const index = BASE32_ALPHABET.indexOf(char);
if (index < 0) continue;
bits += index.toString(2).padStart(5, "0");
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(bytes);
}
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20)); // 160-bit key, standard for authenticator apps
}
function hotp(secretBuffer, counter, digits = 6) {
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac("sha1", secretBuffer).update(counterBuffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
return String(binary % 10 ** digits).padStart(digits, "0");
}
export function totpAt(base32Secret, forTime = Date.now(), step = 30, digits = 6) {
const counter = Math.floor(forTime / 1000 / step);
return hotp(base32Decode(base32Secret), counter, digits);
}
// Accepts a code from the current step or one step on either side, to tolerate normal clock drift.
export function verifyTotp(base32Secret, code, { step = 30, digits = 6, window = 1, forTime = Date.now() } = {}) {
const candidate = String(code || "").trim().replace(/\s+/g, "");
if (!/^\d{6,8}$/.test(candidate)) return false;
const secretBuffer = base32Decode(base32Secret);
const baseCounter = Math.floor(forTime / 1000 / step);
for (let offset = -window; offset <= window; offset++) {
const expected = hotp(secretBuffer, baseCounter + offset, digits);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.padStart(digits, "0")))) return true;
}
return false;
}
export function otpauthUri({ secret, username, issuer = "Site Gateway" }) {
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`;
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
export function generateRecoveryCodes(count = 10) {
const codes = [];
for (let i = 0; i < count; i++) {
const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); // 10 hex chars
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
}
return codes;
}