Annotate server.js, storage.js, app.js, features.js, and index.html with section/function comments

This commit is contained in:
marvin
2026-09-16 10:54:49 -04:00
parent b62f27f415
commit cb1bdc6aca
5 changed files with 449 additions and 0 deletions
+115
View File
@@ -1,11 +1,25 @@
// ============================================================================
// app.js -- core client application: state, API helper, theme, dashboard,
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
// edit/icon/user/account/MFA), and every event listener for those areas. The
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
// live in features.js and are invoked from here via window.renderExtendedViews.
// ============================================================================
// --- Shared DOM shortcut and app state ----------------------------------------
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
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 }; 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 };
// One-time DOM patches: move the Access List field into the create/settings
// forms (features.js owns the Access List data, this file owns these forms).
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain "); 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("#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); } } 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); } }
const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel); const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel);
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel); const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove(); document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
// --- Theme (light/dark/system) -------------------------------------------------
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme(preference) { function applyTheme(preference) {
@@ -18,17 +32,22 @@ $("#theme-select").value = savedTheme; applyTheme(savedTheme);
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); }); $("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); }); systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
// --- API helper ------------------------------------------------------------------
async function api(url, options = {}) { async function api(url, options = {}) {
const response = await fetch(url, options); const response = await fetch(url, options);
if (response.status === 401) { showLogin(); throw new Error("Please sign in again."); } if (response.status === 401) { showLogin(); throw new Error("Please sign in again."); }
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); } if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
return response.status === 204 ? null : response.json(); return response.status === 204 ? null : response.json();
} }
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
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 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 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 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; } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; } function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
function formatBytes(value) { function formatBytes(value) {
if (!Number.isFinite(value)) return "Unavailable"; if (!Number.isFinite(value)) return "Unavailable";
if (value < 1024) return `${value} B`; if (value < 1024) return `${value} B`;
@@ -36,15 +55,18 @@ function formatBytes(value) {
for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; } for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; }
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`; return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
} }
function formatDuration(seconds) { function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return "Unavailable"; if (!Number.isFinite(seconds)) return "Unavailable";
const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60); const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60);
if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`; if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`;
} }
function formatTime(value) { function formatTime(value) {
if (!value) return "Just now"; if (!value) return "Just now";
const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
} }
function formatRelativeTime(value) { function formatRelativeTime(value) {
if (!value) return "Just now"; if (!value) return "Just now";
const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently";
@@ -59,19 +81,25 @@ function formatRelativeTime(value) {
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
return formatTime(value); 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 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 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); } function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
// event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of // event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of
// clicking the button), which previously crashed every save handler below on `button.disabled = true` // clicking the button), which previously crashed every save handler below on `button.disabled = true`
// and silently dropped the whole save. Fall back to the form's actual submit button. // 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 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 scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
// #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the // #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 // 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 // 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. // 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 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; }
// advancedFormBody -- reads the scoped or unscoped "advanced options" fields off
// a create/edit form and merges them into the outgoing request body.
function advancedFormBody(form, body, scoped) { function advancedFormBody(form, body, scoped) {
// scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where // 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 // field names collide with another section, so every ambiguous field is read from its own scope instead of
@@ -88,6 +116,8 @@ function advancedFormBody(form, body, scoped) {
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText; delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
return body; return body;
} }
// --- Proxy/Hosted settings dialog: submit handler ---------------------------------
// Single capture-path for monitoring settings: unchecked checkboxes must be sent as false. // Single capture-path for monitoring settings: unchecked checkboxes must be sent as false.
document.addEventListener("submit", async event => { document.addEventListener("submit", async event => {
if (event.target?.id !== "settings-form" || !state.editing) return; if (event.target?.id !== "settings-form" || !state.editing) return;
@@ -112,6 +142,8 @@ document.addEventListener("submit", async event => {
finally { button.disabled = false; } finally { button.disabled = false; }
}, true); }, true);
// --- Dashboard rendering ----------------------------------------------------------
function healthCopy(group, label) { function healthCopy(group, label) {
if (!group.total) return "Nothing configured"; if (!group.total) return "Nothing configured";
if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`; if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`;
@@ -126,6 +158,7 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
return service.status === "ready" ? ready : error; return service.status === "ready" ? ready : error;
} }
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; 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); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); 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>`; } function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; 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); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); 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>`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); } function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
function renderDashboard() { function renderDashboard() {
@@ -181,8 +214,11 @@ function renderDashboard() {
$("#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>'; $("#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>'; $("#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); setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
// --- Card rendering helpers (icons, permissions) -----------------------------------
function initials(name) { function initials(name) {
const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean); const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean);
if (!words.length) return "??"; if (!words.length) return "??";
@@ -193,6 +229,8 @@ document.addEventListener('error', event => { const image = event.target; if (!(
function canManage() { return ["administrator", "standard"].includes(state.user?.role); } function canManage() { return ["administrator", "standard"].includes(state.user?.role); }
function canAdmin() { return state.user?.role === "administrator"; } function canAdmin() { return state.user?.role === "administrator"; }
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
function hostedCard(site) { function hostedCard(site) {
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled"; const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`; const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
@@ -209,6 +247,8 @@ function proxyCard(proxy) {
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</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}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`; return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</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}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
} }
// --- Certificates view --------------------------------------------------------------
function renderCertificates() { function renderCertificates() {
const data = state.certificates; if (!data) return; const data = state.certificates; if (!data) return;
$("#certificate-count").textContent = data.summary.total; $("#certificate-count").textContent = data.summary.total;
@@ -219,6 +259,8 @@ function renderCertificates() {
renderReadiness(); renderReadiness();
} }
// --- Domain readiness (used inside the Certificates view) ---------------------------
function renderReadiness() { function renderReadiness() {
const routes = state.readiness?.routes || []; const routes = state.readiness?.routes || [];
$("#readiness-list").innerHTML = routes.length ? routes.map(item => { $("#readiness-list").innerHTML = routes.length ? routes.map(item => {
@@ -232,6 +274,8 @@ function renderReadiness() {
}).join("") : '<p class="quiet-state">No configured domains to check.</p>'; }).join("") : '<p class="quiet-state">No configured domains to check.</p>';
} }
// --- Logs view -------------------------------------------------------------------------
function renderLogs() { function renderLogs() {
const data = state.logs; if (!data) return; 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; 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;
@@ -245,6 +289,9 @@ 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>'; $("#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>';
} }
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
// and the per-domain throughput table -----------------------------------------------
function renderPerformance() { function renderPerformance() {
const data = state.performance; if (!data) return; const data = state.performance; if (!data) return;
const selected = $("#performance-host").value; const selected = $("#performance-host").value;
@@ -296,6 +343,8 @@ function renderPerformance() {
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" }); if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
} }
// --- Administration > Users view ---------------------------------------------------------
function renderUsers() { function renderUsers() {
const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 }; 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; }); 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; });
@@ -315,6 +364,8 @@ function renderUsers() {
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); }); 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); });
} }
// --- Account panel (profile, MFA status) --------------------------------------------------
function renderAccount() { function renderAccount() {
if (!state.user) return; if (!state.user) return;
$("#account-display-name").textContent = state.user.displayName || "—"; $("#account-display-name").textContent = state.user.displayName || "—";
@@ -328,6 +379,8 @@ function renderAccount() {
$("#account-mfa-recovery").classList.toggle("hidden", !enabled); $("#account-mfa-recovery").classList.toggle("hidden", !enabled);
} }
// --- View routing: what data to (re)load and what to show for state.view ------------------
async function loadFeatureView() { async function loadFeatureView() {
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); } 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 === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
@@ -336,6 +389,9 @@ async function loadFeatureView() {
if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.(); if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.();
restoreAdminTab(); restoreAdminTab();
} }
// render() -- the main view switcher. Shows/hides each top-level section based on
// state.view, and for the Hosted/Proxy "management" view, renders the card grid,
// empty state, and summary indicator bar directly.
function render() { function render() {
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view; const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`); if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`);
@@ -386,6 +442,8 @@ function render() {
$("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#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"}`; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`;
} }
// --- Data refresh helpers ------------------------------------------------------------------
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 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 = []) { async function refreshPendingProxies(ids = []) {
const pending = new Set(ids.map(String)); const pending = new Set(ids.map(String));
@@ -401,6 +459,8 @@ async function refreshDashboard() {
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); } try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); } finally { button.disabled = false; button.classList.remove("spinning"); }
} }
// --- Boot: session check, initial routing, periodic health/update checks -------------------
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); } function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); }
async function boot() { async function boot() {
const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`); const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
@@ -417,26 +477,38 @@ async function boot() {
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000); 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); if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
} }
async function checkForUpdate() { async function checkForUpdate() {
if (state.updateAvailable || !state.loadedVersion) return; if (state.updateAvailable || !state.loadedVersion) return;
const config = await api("/api/config"); const config = await api("/api/config");
if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); } if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); }
} }
// --- Update-available banner --------------------------------------------------------------
$("#update-banner-refresh").addEventListener("click", () => location.reload()); $("#update-banner-refresh").addEventListener("click", () => location.reload());
$("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; }); $("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; });
// --- Login, MFA login, first-run setup, and logout -----------------------------------------
$("#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; } }); $("#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-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); }); $("#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-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()); $("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); }); $("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
// --- Dashboard actions: run certificate check, download support report, jump to
// an attention item's view -------------------------------------------------------------
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } }); $("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; }); $("#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)); } }); $("#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)); } });
// --- Primary navigation (sidebar view switching) -------------------------------------------
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); } 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, .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)); } })); 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)); }); $("#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)); });
// --- Logs & Performance filter controls -----------------------------------------------------
$("#refresh-logs").addEventListener("click", () => 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))); $("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); $("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
@@ -444,6 +516,8 @@ $("#performance-range").addEventListener("change", () => loadFeatureView().catch
$("#log-status").addEventListener("change", renderLogs); $("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs); $("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs); $("#event-category").addEventListener("change", renderLogs);
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
function openCreate() { function openCreate() {
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); } 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 === "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(); }
@@ -453,15 +527,23 @@ function openCreate() {
$("#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(); $("#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); $("#open-create").addEventListener("click", openCreate);
// --- Global dialog/menu behavior (Escape to close menus, dialog close resets state) ---------
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(); }); 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(); });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); }); document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); });
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); })); document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message))); $("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); $("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
// --- Health-check field visibility polish for the create forms ------------------------------
function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); } function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); }
setInterval(ensureHostedHealthFields, 300); setInterval(ensureHostedHealthFields, 300);
// --- Hosted/Proxy edit ( "Domain & TLS" / "Edit proxy host" ) settings dialog ---------------
function openSettings(kind, id) { function openSettings(kind, id) {
if (kind === "hosted") kind = "site"; 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(); form.querySelectorAll("details").forEach(details => details.open = false); 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);
@@ -482,6 +564,8 @@ function openSettings(kind, id) {
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom"); document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
} }
// --- Hosted/Proxy card actions: toggle / edit / delete / replace files / change icon --------
$("#site-grid").addEventListener("click", async event => { $("#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; 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; 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;
@@ -492,14 +576,21 @@ $("#site-grid").addEventListener("click", async event => {
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); } if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
if (action === "icon") openIconPicker(kind, card.dataset.id); if (action === "icon") openIconPicker(kind, card.dataset.id);
}); });
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
// icon/delete/toggle) ---------------------------------------------------------------------
document.querySelector("#redirect-list")?.addEventListener("click", event => { document.querySelector("#redirect-list")?.addEventListener("click", event => {
const card = event.target.closest(".redirect-card"); if (!card) return; const card = event.target.closest(".redirect-card"); if (!card) return;
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 (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; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); } const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
}); });
// --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; }); $("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; });
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
function openIconPicker(kind, id) { function openIconPicker(kind, id) {
state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0); state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0);
} }
@@ -537,6 +628,8 @@ $("#save-icon-url").addEventListener("click", async () => {
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."); } 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; } catch (error) { $("#icon-error").textContent = error.message; }
}); });
// --- User management: create, edit (role/status), password reset, delete --------------------
$("#user-form").addEventListener("submit", async event => { $("#user-form").addEventListener("submit", async event => {
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = ""; event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = "";
try { try {
@@ -545,6 +638,7 @@ $("#user-form").addEventListener("submit", async event => {
} catch (error) { $("#user-error").textContent = error.message; } } catch (error) { $("#user-error").textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
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 })); } 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 => { $("#user-list").addEventListener("click", async event => {
const menuCard = event.target.closest(".user-card"); const menuCard = event.target.closest(".user-card");
@@ -571,6 +665,7 @@ $("#user-list").addEventListener("click", async event => {
} catch (error) { toast(error.message); } } catch (error) { toast(error.message); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
$("#password-form").addEventListener("submit", async event => { $("#password-form").addEventListener("submit", async event => {
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = ""; event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = "";
try { try {
@@ -579,6 +674,8 @@ $("#password-form").addEventListener("submit", async event => {
} catch (error) { $("#password-error").textContent = error.message; } } catch (error) { $("#password-error").textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
// --- Hash-based routing: back/forward and deep links (#view or #administration/tab) ---------
window.addEventListener("hashchange", () => { window.addEventListener("hashchange", () => {
if (!state.user) return; // Not logged in yet; boot() handles initial routing. if (!state.user) return; // Not logged in yet; boot() handles initial routing.
const requestedHash = location.hash.slice(1); const requestedHash = location.hash.slice(1);
@@ -588,8 +685,11 @@ window.addEventListener("hashchange", () => {
render(); render();
loadFeatureView().catch(error => toast(error.message)); loadFeatureView().catch(error => toast(error.message));
}); });
boot().catch(error => toast(error.message)); boot().catch(error => toast(error.message));
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
function syncUpstreamTlsControls(form) { function syncUpstreamTlsControls(form) {
if (!form || !form.elements.target) return; if (!form || !form.elements.target) return;
const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean); const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean);
@@ -603,14 +703,22 @@ function syncUpstreamTlsControls(form) {
const lbPolicy = form.elements.lbPolicy; 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"; } 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("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); }); 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); });
document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form)); document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form));
// --- Misc global click delegation (create triggers, generic [data-action] handlers) ---------
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 => { 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); }); 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); });
// --- Access Lists: periodic live refresh while that view is open ----------------------------
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); 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 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>`; } 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: change password form ------------------------------------------------------------
$("#account-password-form").addEventListener("submit", async event => { $("#account-password-form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
$("#account-password-error").textContent = ""; $("#account-password-error").textContent = "";
@@ -623,6 +731,9 @@ $("#account-password-form").addEventListener("submit", async event => {
} catch (error) { $("#account-password-error").textContent = error.message; } } catch (error) { $("#account-password-error").textContent = error.message; }
}); });
// --- MFA: password re-confirmation dialog used before disabling MFA or
// regenerating recovery codes --------------------------------------------------------------
let mfaPasswordResolve = null; let mfaPasswordResolve = null;
function requestMfaPassword(title, heading) { function requestMfaPassword(title, heading) {
$("#mfa-password-title").textContent = title; $("#mfa-password-title").textContent = title;
@@ -641,6 +752,8 @@ $("#mfa-password-form").addEventListener("submit", event => {
}); });
$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; }); $("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; });
// --- MFA: enable / setup / confirm flow --------------------------------------------------------
$("#account-mfa-enable").addEventListener("click", async () => { $("#account-mfa-enable").addEventListener("click", async () => {
try { try {
const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
@@ -668,6 +781,8 @@ $("#mfa-setup-confirm-form").addEventListener("submit", async event => {
}); });
$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); }); $("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); });
// --- MFA: disable and regenerate recovery codes -------------------------------------------------
$("#account-mfa-disable").addEventListener("click", async () => { $("#account-mfa-disable").addEventListener("click", async () => {
const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue"); const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue");
if (!password) return; if (!password) return;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+96
View File
@@ -69,6 +69,8 @@ const probeFailures = { gateway: 0, http: 0, https: 0 };
let iconCatalog = null; let iconCatalog = null;
let storage; let storage;
// --- Small utility helpers (activity log, dir sizing, env parsing, passwords) -----------
function recordActivity(message, status = "ok") { function recordActivity(message, status = "ok") {
const entry = { message, status, at: new Date().toISOString() }; const entry = { message, status, at: new Date().toISOString() };
recentActivity.unshift(entry); recentActivity.unshift(entry);
@@ -121,6 +123,8 @@ function activeAdministrators() {
return users.filter(user => user.role === "administrator" && user.status === "active"); return users.filter(user => user.role === "administrator" && user.status === "active");
} }
// --- Sessions & auth cookies --------------------------------------------------------------
function slugify(value) { function slugify(value) {
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48); return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
} }
@@ -151,6 +155,8 @@ const saveStreams = async () => storage.saveCollection("streams", streams);
const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists); const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists);
const saveSettings = async () => storage.saveSettings(settings); const saveSettings = async () => storage.saveSettings(settings);
// --- Data loading (hosted sites) and shared validation helpers -----------------------------
async function clearDirectoryContents(directory) { async function clearDirectoryContents(directory) {
await fsp.mkdir(directory, { recursive: true }); await fsp.mkdir(directory, { recursive: true });
let lastError = null; let lastError = null;
@@ -214,6 +220,8 @@ async function loadSites() {
await saveSettings(); await saveSettings();
} }
// --- Domain / target / stream-port validation -----------------------------------------------
function normalizeDomain(value) { function normalizeDomain(value) {
return String(value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, ""); return String(value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "");
} }
@@ -263,6 +271,8 @@ function streamPortConflict(port, exceptId) {
return null; return null;
} }
// --- Header / location / custom-config sanitizing for Proxy & Hosted advanced options -------
function cleanHeaders(value) { function cleanHeaders(value) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() })) return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() }))
@@ -285,6 +295,7 @@ function cleanCustomConfig(value) {
return config; return config;
} }
function applyAdvancedSettings(item, body) { function applyAdvancedSettings(item, body) {
if (body.upstreams !== undefined) { if (body.upstreams !== undefined) {
if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 }); if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 });
@@ -313,6 +324,9 @@ function applyAdvancedSettings(item, body) {
if (body.locations !== undefined) item.locations = cleanLocations(body.locations); if (body.locations !== undefined) item.locations = cleanLocations(body.locations);
} }
// --- Caddyfile generation: turns hosted sites/proxies/redirects/streams/access lists
// into the actual Caddy configuration and reloads Caddy with it -------------------------
function expectedStatusMatches(status, specification = "200-499") { function expectedStatusMatches(status, specification = "200-499") {
return String(specification).split(",").some(part => { return String(specification).split(",").some(part => {
const value = part.trim(); const value = part.trim();
@@ -383,6 +397,10 @@ function proxyBlock(target, item, indent = " ") {
return output; return output;
} }
// Writes the themed default ("no route configured") static HTML page to disk. Keep
// this HTML in sync with the client-side preview in features.js's
// defaultSiteThemedHtml() -- see the comment there.
async function writeDefaultSitePage() { async function writeDefaultSitePage() {
const selected = settings.defaultSite || {}; const selected = settings.defaultSite || {};
const title = String(selected.title || (selected.mode === "welcome" ? "Gateway ready" : "Route not found")).replace(/[<>]/g, ""); const title = String(selected.title || (selected.mode === "welcome" ? "Gateway ready" : "Route not found")).replace(/[<>]/g, "");
@@ -393,6 +411,9 @@ async function writeDefaultSitePage() {
await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html); await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html);
} }
// renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state
// (sites, proxies, redirects, streams, access lists, default site settings).
function renderCaddyfile() { function renderCaddyfile() {
const email = String(process.env.ACME_EMAIL || "").trim(); const email = String(process.env.ACME_EMAIL || "").trim();
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`]; const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
@@ -425,6 +446,9 @@ function renderCaddyfile() {
return `${lines.join("\n")}\n`; return `${lines.join("\n")}\n`;
} }
// syncCaddy -- applies the generated config to the running Caddy instance and
// records success/failure (gatewayError, lastGatewayReload) for the dashboard.
async function syncCaddy() { async function syncCaddy() {
const nextPath = `${caddyfilePath}.next`; const nextPath = `${caddyfilePath}.next`;
const previous = await fsp.readFile(caddyfilePath, "utf8").catch(() => null); const previous = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
@@ -457,6 +481,8 @@ async function syncCaddy() {
} }
} }
// --- Status helpers & public (client-facing, secret-stripped) view builders ------------------
function siteStatus(site) { function siteStatus(site) {
if (!site.enabled) return "disabled"; if (!site.enabled) return "disabled";
if (site.domain && gatewayError) return "error"; if (site.domain && gatewayError) return "error";
@@ -477,6 +503,8 @@ function publicStream(stream) {
return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null }; return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null };
} }
// --- Certificate inventory & domain readiness diagnostics --------------------------------------
async function walkFiles(directory) { async function walkFiles(directory) {
const output = []; const output = [];
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) { for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
@@ -541,6 +569,7 @@ async function pruneOrphanedCertificates(candidateDomains) {
if (removed.size) recordActivity(`Removed stored certificate data for ${orphaned.join(", ")} (no longer in use).`); if (removed.size) recordActivity(`Removed stored certificate data for ${orphaned.join(", ")} (no longer in use).`);
} }
async function domainReadiness() { async function domainReadiness() {
const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const certs = await certificateInventory(); const certs = await certificateInventory();
@@ -554,6 +583,8 @@ async function domainReadiness() {
})); }));
} }
// --- Upstream (proxy target) health checks ------------------------------------------------------
async function checkProxy(proxy) { async function checkProxy(proxy) {
if (!proxy.enabled) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(proxy.id, result); return result; } if (!proxy.enabled) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(proxy.id, result); return result; }
if (proxy.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(proxy.id, result); return result; } if (proxy.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(proxy.id, result); return result; }
@@ -584,6 +615,8 @@ async function checkAllProxies() {
const SENSITIVE_QUERY_PARAM_PATTERNS = [/token/i, /secret/i, /password/i, /passwd/i, /auth/i, /session/i, /api[-_]?key/i, /credential/i]; const SENSITIVE_QUERY_PARAM_PATTERNS = [/token/i, /secret/i, /password/i, /passwd/i, /auth/i, /session/i, /api[-_]?key/i, /credential/i];
// --- Access log ingestion (tailing Caddy's access log into SQLite) ------------------------------
function redactUri(uri) { function redactUri(uri) {
const str = String(uri || ""); const str = String(uri || "");
const queryIndex = str.indexOf("?"); const queryIndex = str.indexOf("?");
@@ -624,6 +657,8 @@ async function importAccessLogsToSqlite() {
} catch (error) { console.warn("Could not import access logs into SQLite:", error.message); } } catch (error) { console.warn("Could not import access logs into SQLite:", error.message); }
} }
// --- Raw TCP probing, used for streaming-host health checks --------------------------------------
function tcpProbe(port, timeoutMs = 1000) { function tcpProbe(port, timeoutMs = 1000) {
return new Promise(resolve => { return new Promise(resolve => {
const socket = net.createConnection({ host: "127.0.0.1", port }); const socket = net.createConnection({ host: "127.0.0.1", port });
@@ -655,6 +690,8 @@ function stableProbe(name, responding) {
: { status: "error", healthy: false, responding: false }; : { status: "error", healthy: false, responding: false };
} }
// --- Icon catalog (searchable dashboard-icons list) & icon caching -------------------------------
async function loadIconCatalog() { async function loadIconCatalog() {
if (iconCatalog) return iconCatalog; if (iconCatalog) return iconCatalog;
try { try {
@@ -691,6 +728,9 @@ async function cacheIcon(slug) {
return `/site-icons/${filename}`; return `/site-icons/${filename}`;
} }
// --- Dashboard snapshot: aggregates health/status across every subsystem for the
// Overview page and the /api/dashboard endpoint --------------------------------------------
async function dashboardSnapshot() { async function dashboardSnapshot() {
const hosted = sites.map(publicSite); const hosted = sites.map(publicSite);
const proxyHosts = proxies.map(publicProxy); const proxyHosts = proxies.map(publicProxy);
@@ -757,6 +797,8 @@ async function dashboardSnapshot() {
}; };
} }
// --- Hosted site process/lifecycle control --------------------------------------------------------
async function startSite(site) { async function startSite(site) {
if (!site.enabled || activeServers.has(site.id)) return; if (!site.enabled || activeServers.has(site.id)) return;
const root = path.join(sitesDir, site.id); const root = path.join(sitesDir, site.id);
@@ -788,6 +830,8 @@ async function restartSite(site) {
// Streaming hosts relay raw TCP/UDP on a specific port straight to a host:port target — no domain, no HTTP, // 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 // 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. // Gateway owns directly, just for a plain socket instead of an HTTP server.
// --- Streaming host process/lifecycle control -------------------------------------------------------
async function startStream(stream) { async function startStream(stream) {
if (stream.enabled === false || activeStreams.has(stream.id)) return; if (stream.enabled === false || activeStreams.has(stream.id)) return;
const [targetHost, targetPortRaw] = String(stream.target || "").split(":"); const [targetHost, targetPortRaw] = String(stream.target || "").split(":");
@@ -869,6 +913,8 @@ async function checkStream(stream) {
return result; return result;
} }
// --- Upload handling (hosted site ZIP install) ------------------------------------------------------
function validatePort(port, exceptId) { function validatePort(port, exceptId) {
if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`; 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."; if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned.";
@@ -913,6 +959,8 @@ async function installUpload(site, file) {
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 }; 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 };
// --- Backups: create / open / list / restore, including encryption -----------------------------------
async function protectBackup(buffer, password) { async function protectBackup(buffer, password) {
if (!password) return buffer; if (!password) return buffer;
const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12), key = await scryptAsync(password, salt, 32), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12), key = await scryptAsync(password, salt, 32), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
@@ -1041,6 +1089,11 @@ const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024,
const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } }); const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } });
const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024, files: 1 } }); const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024, files: 1 } });
app.disable("x-powered-by"); app.disable("x-powered-by");
// ============================================================================================
// HTTP layer: Express app setup, auth middleware, and every /api/* route.
// Routes below are grouped by area; see the section comments for each group.
// ============================================================================================
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
app.get(["/", "/index.html"], (req, res) => { app.get(["/", "/index.html"], (req, res) => {
@@ -1052,6 +1105,8 @@ app.get(["/", "/index.html"], (req, res) => {
app.use(express.static(publicDir)); app.use(express.static(publicDir));
app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") })); app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") }));
// --- Session / login / MFA login / logout ------------------------------------------------------------
app.get("/api/session", (req, res) => { app.get("/api/session", (req, res) => {
const user = sessionUser(req); 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 }); res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null });
@@ -1129,6 +1184,8 @@ app.post("/api/logout", (req, res) => {
res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0");
res.json({ ok: true }); res.json({ ok: true });
}); });
// --- Public access-check endpoint used by Caddy's forward_auth for Access Lists -----------------------
function accessSession(req, listId) { function accessSession(req, listId) {
const token = cookieMap(req.headers.cookie).site_gateway_access; if (!token) return null; const token = cookieMap(req.headers.cookie).site_gateway_access; if (!token) return null;
const [storedList, username, expires, signature] = token.split("."); const [storedList, username, expires, signature] = token.split(".");
@@ -1143,6 +1200,8 @@ app.get("/api/access-check", (req, res) => {
const original = String(req.headers["x-forwarded-uri"] || "/"); const safeReturn = original.startsWith("/") && !original.startsWith("//") ? original : "/"; const original = String(req.headers["x-forwarded-uri"] || "/"); const safeReturn = original.startsWith("/") && !original.startsWith("//") ? original : "/";
res.redirect(302, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}`); res.redirect(302, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}`);
}); });
// --- Themed login page served for Access-List-protected routes ------------------------------------------
app.get("/_site-gateway/login", (req, res) => { app.get("/_site-gateway/login", (req, res) => {
const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false);
if (!list) return res.status(404).send("Access policy not found."); const safeReturn = String(req.query.return || "/").startsWith("/") ? String(req.query.return || "/") : "/"; if (!list) return res.status(404).send("Access policy not found."); const safeReturn = String(req.query.return || "/").startsWith("/") ? String(req.query.return || "/") : "/";
@@ -1157,6 +1216,8 @@ app.post("/_site-gateway/login", async (req, res, next) => {
res.setHeader("Set-Cookie", `site_gateway_access=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure}`); res.redirect(303, safeReturn); res.setHeader("Set-Cookie", `site_gateway_access=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure}`); res.redirect(303, safeReturn);
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- First-run admin setup ---------------------------------------------------------------------------------
app.use("/api", (req, res, next) => { app.use("/api", (req, res, next) => {
const user = sessionUser(req); const user = sessionUser(req);
if (!user) return res.status(401).json({ error: "Please sign in." }); if (!user) return res.status(401).json({ error: "Please sign in." });
@@ -1182,8 +1243,12 @@ app.post("/api/setup/admin", async (req, res, next) => {
res.json({ ok: true }); res.json({ ok: true });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Everything below requires an authenticated session (auth middleware applied above) --------------------
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) => { 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.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.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." }); });
// --- Account: password change & MFA setup/confirm/disable/recovery-codes -----------------------------------
app.post("/api/account/password", async (req, res, next) => { app.post("/api/account/password", async (req, res, next) => {
try { try {
const currentPassword = String(req.body.currentPassword || ""); const currentPassword = String(req.body.currentPassword || "");
@@ -1242,6 +1307,8 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
res.json({ ok: true, recoveryCodes: codes }); res.json({ ok: true, recoveryCodes: codes });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Config, Users, Audit log, Groups, Access List <-> Group assignment --------------------------------------
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/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/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." })); 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." }));
@@ -1310,6 +1377,8 @@ app.post("/api/access-lists/:id/groups", async (req, res, next) => { try { if (r
app.post("/api/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); if (groups.some(group => group.name.toLowerCase() === name.toLowerCase())) return res.status(409).json({ error: "That group already exists." }); const group = { id: "group-" + crypto.randomBytes(4).toString("hex"), name, enabled: true, members: [], createdAt: new Date().toISOString() }; groups.push(group); await saveGroups(); recordActivity("Group “" + name + "” created."); res.status(201).json(group); } catch (error) { next(error); } }); app.post("/api/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); if (groups.some(group => group.name.toLowerCase() === name.toLowerCase())) return res.status(409).json({ error: "That group already exists." }); const group = { id: "group-" + crypto.randomBytes(4).toString("hex"), name, enabled: true, members: [], createdAt: new Date().toISOString() }; groups.push(group); await saveGroups(); recordActivity("Group “" + name + "” created."); res.status(201).json(group); } catch (error) { next(error); } });
app.patch("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const group = groups.find(value => value.id === req.params.id); if (!group) return res.status(404).json({ error: "Group not found." }); if (req.body.name !== undefined) { const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); group.name = name; } if (req.body.enabled !== undefined) group.enabled = Boolean(req.body.enabled); if (Array.isArray(req.body.members)) group.members = [...new Set(req.body.members)].filter(id => users.some(user => user.id === id)); await saveGroups(); recordActivity("Group “" + group.name + "” updated."); res.json(group); } catch (error) { next(error); } }); app.patch("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const group = groups.find(value => value.id === req.params.id); if (!group) return res.status(404).json({ error: "Group not found." }); if (req.body.name !== undefined) { const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); group.name = name; } if (req.body.enabled !== undefined) group.enabled = Boolean(req.body.enabled); if (Array.isArray(req.body.members)) group.members = [...new Set(req.body.members)].filter(id => users.some(user => user.id === id)); await saveGroups(); recordActivity("Group “" + group.name + "” updated."); res.json(group); } catch (error) { next(error); } });
app.delete("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const index = groups.findIndex(value => value.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Group not found." }); const [group] = groups.splice(index, 1); await saveGroups(); recordActivity("Group “" + group.name + "” deleted."); res.status(204).end(); } catch (error) { next(error); } }); app.delete("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const index = groups.findIndex(value => value.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Group not found." }); const [group] = groups.splice(index, 1); await saveGroups(); recordActivity("Group “" + group.name + "” deleted."); res.status(204).end(); } catch (error) { next(error); } });
// --- Settings, dashboard, certificates, health checks, domain readiness ---------------------------------------
app.get("/api/settings", (req, res) => req.user.role === "administrator" ? res.json({ ...settings, backupDirectory: backupsDir }) : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/settings", (req, res) => req.user.role === "administrator" ? res.json({ ...settings, backupDirectory: backupsDir }) : res.status(403).json({ error: "Administrator access is required." }));
app.post("/api/settings/verify-admin", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error:"Administrator username or password is incorrect." }); res.json({ ok:true }); } catch (error) { next(error); } }); app.post("/api/settings/verify-admin", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error:"Administrator username or password is incorrect." }); res.json({ ok:true }); } catch (error) { next(error); } });
app.post("/api/settings/verify-username", (req, res) => { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); const username = String(req.body.username || "").trim().toLowerCase(); res.json({ valid: Boolean(username && username === String(req.user.username || "").toLowerCase()) }); }); app.post("/api/settings/verify-username", (req, res) => { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); const username = String(req.body.username || "").trim().toLowerCase(); res.json({ valid: Boolean(username && username === String(req.user.username || "").toLowerCase()) }); });
@@ -1326,6 +1395,10 @@ app.post("/api/health/check", async (req, res, next) => {
catch (error) { next(error); } catch (error) { next(error); }
}); });
app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } }); app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } });
// GET /api/support-report -- generates the downloadable diagnostics report (gateway
// health, storage integrity, every route's config, certificate status, domain
// readiness, and recent activity) used for troubleshooting.
app.get("/api/support-report", async (req, res, next) => { app.get("/api/support-report", async (req, res, next) => {
try { try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
@@ -1335,6 +1408,8 @@ app.get("/api/support-report", async (req, res, next) => {
res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2)); res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Upstream health, Logs, and Performance (request throughput/trend) endpoints --------------------------------
app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy))); app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy)));
app.post("/api/upstreams/check", async (req, res, next) => { app.post("/api/upstreams/check", async (req, res, next) => {
try { res.json(await checkAllProxies()); } try { res.json(await checkAllProxies()); }
@@ -1363,6 +1438,8 @@ app.get("/api/performance", (req, res, next) => {
}); });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Icon search and per-entity icon upload/URL/removal -----------------------------------------------------------
app.get("/api/icons/search", async (req, res, next) => { app.get("/api/icons/search", async (req, res, next) => {
try { try {
const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80); const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80);
@@ -1422,6 +1499,8 @@ app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next
} catch (error) { next(error); } } catch (error) { next(error); }
finally { if (req.file?.path) await fsp.rm(req.file.path, { force: true }).catch(() => {}); } finally { if (req.file?.path) await fsp.rm(req.file.path, { force: true }).catch(() => {}); }
}); });
// --- Hosted Sites: create / toggle / replace files / delete / edit --------------------------------------------------
app.post("/api/sites", upload.single("files"), async (req, res, next) => { app.post("/api/sites", upload.single("files"), async (req, res, next) => {
try { try {
const name = String(req.body.name || "").trim(); const name = String(req.body.name || "").trim();
@@ -1516,6 +1595,8 @@ app.patch("/api/sites/:id", async (req, res, next) => {
res.json(publicSite(site)); res.json(publicSite(site));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Proxy Hosts: create / edit / custom certificate upload / toggle / delete ---------------------------------------
app.post("/api/proxies", async (req, res, next) => { app.post("/api/proxies", async (req, res, next) => {
try { try {
const name = String(req.body.name || "").trim(); const name = String(req.body.name || "").trim();
@@ -1620,6 +1701,8 @@ app.delete("/api/proxies/:id", async (req, res, next) => {
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Access Lists: create / edit / assignments / delete ------------------------------------------------------------
app.post("/api/access-lists", async (req, res, next) => { app.post("/api/access-lists", async (req, res, next) => {
try { try {
const name = String(req.body.name || "").trim(); const name = String(req.body.name || "").trim();
@@ -1685,6 +1768,8 @@ app.delete("/api/access-lists/:id", async (req, res, next) => {
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Redirect Hosts: create / edit / delete -------------------------------------------------------------------------
app.post("/api/redirects", async (req, res, next) => { app.post("/api/redirects", async (req, res, next) => {
try { try {
const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const target = String(req.body.target || "").trim().replace(/\/$/, ""); const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const target = String(req.body.target || "").trim().replace(/\/$/, "");
@@ -1713,6 +1798,8 @@ 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); } 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); }
}); });
// --- Streaming Hosts: list / create / edit / toggle / delete -----------------------------------------------------------
app.get("/api/streams", (req, res) => res.json(streams.map(publicStream))); app.get("/api/streams", (req, res) => res.json(streams.map(publicStream)));
app.post("/api/streams", async (req, res, next) => { app.post("/api/streams", async (req, res, next) => {
try { try {
@@ -1777,6 +1864,8 @@ app.delete("/api/streams/:id", async (req, res, next) => {
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Settings (general), log retention/pruning, log download, factory reset ---------------------------------------------
app.patch("/api/settings", async (req, res, next) => { app.patch("/api/settings", async (req, res, next) => {
try { try {
if (req.body.defaultSite) { if (req.body.defaultSite) {
@@ -1802,6 +1891,8 @@ app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role
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.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:"complete", 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/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:"complete", 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)); 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.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); } });
// --- Backups: list / create / import / download / restore / delete -----------------------------------------------------
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); 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.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
app.post("/api/backups", async (req, res, next) => { app.post("/api/backups", async (req, res, next) => {
@@ -1825,8 +1916,11 @@ app.post("/api/backups/:filename/restore", async (req, res, next) => {
app.delete("/api/backups/:filename", async (req, res, next) => { 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); } 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, ""); } 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|streams|access-lists)(\/|$)/i; const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|streams|access-lists)(\/|$)/i;
// --- Error handling middleware & server startup -------------------------------------------------------------------------
app.use((error, req, res, next) => { app.use((error, req, res, next) => {
console.error(error); console.error(error);
const rawMessage = error.message || "Something went wrong."; const rawMessage = error.message || "Something went wrong.";
@@ -1848,6 +1942,8 @@ app.listen(adminPort, "0.0.0.0", () => {
setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref(); setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref(); setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown ---------------------------------
async function runScheduledBackup() { async function runScheduledBackup() {
const schedule = settings.backups || {}; if (!schedule.enabled || Number(schedule.hour) !== new Date().getHours()) return; const schedule = settings.backups || {}; if (!schedule.enabled || Number(schedule.hour) !== new Date().getHours()) return;
const last = schedule.lastRunAt ? new Date(schedule.lastRunAt) : null; const elapsed = last ? Date.now() - last.getTime() : Infinity; const last = schedule.lastRunAt ? new Date(schedule.lastRunAt) : null; const elapsed = last ? Date.now() - last.getTime() : Infinity;
+91
View File
@@ -1,3 +1,11 @@
// ============================================================================
// storage.js -- SQLite persistence layer for Site Gateway.
// Owns the on-disk database, one-time legacy JSON migration, and every
// read/write function server.js uses to load and save app data (sites,
// proxies, redirects, streams, access lists, users, groups, settings,
// activity/audit logs, and request performance data).
// ============================================================================
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
@@ -5,6 +13,10 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
// Legacy pre-SQLite storage: each entity kind used to live in its own JSON
// file under the data directory. entityTables maps the same kinds to their
// current SQLite table names.
export const LOCAL_INSTANCE_ID = "local"; export const LOCAL_INSTANCE_ID = "local";
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "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 legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
@@ -12,6 +24,11 @@ const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects:
function now() { return new Date().toISOString(); } function now() { return new Date().toISOString(); }
// One-time safety snapshot taken before migrating legacy JSON files into
// SQLite: zips up the JSON files plus related data directories (sites,
// icons, default-site, certificates) into a timestamped .sgbackup archive
// so the pre-migration state is always recoverable.
async function migrationSnapshot(dataDir, backupsDir, migrationsDir) { async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name))); const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name)));
if (!present.length) return null; if (!present.length) return null;
@@ -31,6 +48,11 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName); manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName);
manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")])); manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")]));
zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2))); zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2)));
// openStorage -- the single entry point server.js calls at boot. Ensures the
// data directories and SQLite database exist, runs schema setup and the
// legacy JSON migration (if needed), and returns the full set of
// read/write functions used throughout the app.
const filename = `pre-sqlite-migration-${stamp}.sgbackup`; const filename = `pre-sqlite-migration-${stamp}.sgbackup`;
await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 }); await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 });
return { filename, snapshotDir }; return { filename, snapshotDir };
@@ -39,6 +61,13 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
export async function openStorage(dataDir, backupsDir) { export async function openStorage(dataDir, backupsDir) {
const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite"); const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite");
await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]); await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]);
// --- Schema setup -----------------------------------------------------
// Core entity tables (hosted sites, proxy hosts, redirect hosts, stream
// hosts, access lists, users, groups) each store their record as a JSON
// payload column, plus supporting tables for access-list assignments,
// settings, audit/activity logs, and raw request (access) events used
// for performance reporting.
const isNew = !fs.existsSync(databasePath); const isNew = !fs.existsSync(databasePath);
const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null; const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null;
const db = new DatabaseSync(databasePath); const db = new DatabaseSync(databasePath);
@@ -61,18 +90,31 @@ export async function openStorage(dataDir, backupsDir) {
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(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 users_instance ON users(instance_id);
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id); CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
// Forward-compatible column add for databases created before "category"
// existed on activity_events; a no-op once the column is already there.
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id)); CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC); CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
// --- Core entity read/write --------------------------------------------
// transaction() wraps a block of statements in BEGIN IMMEDIATE/COMMIT,
// rolling back on any thrown error.
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source)); CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
// loadCollection -- reads every record of one entity kind (sites, proxies,
// redirects, streams, access_lists, users, groups) for an instance.
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC); CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
// refreshAssignments -- rebuilds the access_assignments table (which route
// is protected by which Access List) from the current hosted/proxy/
// redirect payloads. Called after any save that could change accessListId.
`); `);
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ } try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
const timestamp = now(); const timestamp = now();
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp); db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp); db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
// saveCollection -- replaces (or, for access_lists, upserts/prunes) all
// records of one entity kind for an instance, inside a single transaction.
function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }
function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); } function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); }
@@ -91,17 +133,37 @@ export async function openStorage(dataDir, backupsDir) {
const created = value.createdAt || now(), stored = { ...value, instanceId }; const created = value.createdAt || now(), stored = { ...value, instanceId };
if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom")); if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom"));
insert.run(value.id, instanceId, JSON.stringify(stored), created, now()); insert.run(value.id, instanceId, JSON.stringify(stored), created, now());
// --- Settings -----------------------------------------------------------
} }
if (kind === "access_lists") { if (kind === "access_lists") {
const keep = new Set(values.map(value => value.id)); const keep = new Set(values.map(value => value.id));
// --- Database health ------------------------------------------------------
for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id); for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id);
} }
// --- Audit & activity logs -----------------------------------------------
// recordAudit -- administrative/security audit trail (who did what).
if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId); if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId);
// recordActivity -- user-facing activity feed (what happened), auto-
// categorized into certificate/security/activity based on the message text.
}); });
} }
// --- Access (request) events, powering Logs and Performance ---------------
// recordAccessEvents -- bulk-inserts raw request log lines tailed from
// Caddy's access log; ON IGNORE + UNIQUE(instance_id,source) makes re-
// ingesting the same log line idempotent.
function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; } function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; }
function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); } function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); }
// performanceLiveCount -- request count within the last windowSeconds,
// used for the "live requests" figure on the dashboard.
function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); } function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); }
// performanceRoutes -- per-domain request/error/avg-response-time totals
// for the last hour and last 24 hours; backs the "Throughput by domain"
// table on the Performance page. A domain only appears here if it has
// at least one request within the last 24 hours (the dayCutoff filter).
function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); } function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); }
function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); } function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); }
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 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))); }
@@ -115,6 +177,9 @@ export async function openStorage(dataDir, backupsDir) {
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests, 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, SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs, AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
// performanceErrorBreakdown -- per-domain, per-status-code error counts
// over the last 24 hours; feeds the error-breakdown detail shown per row
// in the Performance table (top statuses per host).
COUNT(*) AS dayRequests, COUNT(*) AS dayRequests,
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors, SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
AVG(duration_ms) AS dayAvgMs AVG(duration_ms) AS dayAvgMs
@@ -123,6 +188,9 @@ export async function openStorage(dataDir, backupsDir) {
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff); `).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
} }
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) { function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
// performanceTrend -- bucketed request counts over a configurable window
// (default 6 hours, 15-minute buckets), optionally filtered to one host;
// backs the "Requests" trend chart on the Performance page.
const dayCutoff = new Date(Date.now() - 86400000).toISOString(); const dayCutoff = new Date(Date.now() - 86400000).toISOString();
return db.prepare(` return db.prepare(`
SELECT host, status, COUNT(*) AS count SELECT host, status, COUNT(*) AS count
@@ -136,11 +204,29 @@ export async function openStorage(dataDir, backupsDir) {
const cutoff = new Date(Date.now() - windowMs).toISOString(); 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 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(); const buckets = new Map();
// --- Log retention / pruning ----------------------------------------------
// pruneEvents -- deletes access/activity/audit rows older than the
// configured retention policy (per category: access, activity, certificate,
// security, audit), returning how many rows were removed per category.
// Used by both the manual "prune now" action and the scheduled job.
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); } 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); }
// previewPruneEvents -- same policy/cutoffs as pruneEvents but read-only;
// used to show "this will remove N records" before the user confirms.
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs; const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
const points = []; const points = [];
// --- Backups ---------------------------------------------------------------
// backupTo -- writes a consistent point-in-time copy of the SQLite database
// to `filename` using VACUUM INTO (safe to run against a live database).
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 }); for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
return points; return points;
// --- One-time legacy JSON -> SQLite migration --------------------------------
// Runs only when the database file didn't exist yet (isNew). Reads any
// legacy *.json files found in the data directory, inserts their records
// into the new SQLite tables inside a transaction, and rolls the whole
// database file back if anything fails partway through.
} }
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 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 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; }
@@ -160,6 +246,11 @@ export async function openStorage(dataDir, backupsDir) {
} }
} }
const settingsFile = path.join(dataDir, "settings.json"); const settingsFile = path.join(dataDir, "settings.json");
// --- One-off cleanup of a previously confusing error message ---------------
// humanizeGatewayErrors -- rewrites a specific raw Caddy error string that
// used to appear verbatim in the activity/audit logs into a plain-language
// explanation. Runs at boot so existing log rows get the friendlier text too.
if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp); if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp);
refreshAssignments(LOCAL_INSTANCE_ID); refreshAssignments(LOCAL_INSTANCE_ID);
}); } catch (error) { }); } catch (error) {