Fix log token leakage, orphaned certs, hash routing, icon-reset label, docs/backup theme contrast, and version drift

- Redact sensitive query-string params (token/secret/password/etc.) before storing access logs
- Clean up orphaned certificate files when a site/proxy/redirect is deleted, renamed, or has domains removed
- Add hashchange listener so back/forward navigation and deep links correctly route the app
- Fix activity log showing "undefined" instead of a username when resetting/updating a user's icon
- Fix illegible dark-on-dark text in the Docs page hero banner and the Backup & Restore form fields in light theme
- Give raw validation errors on proxy/site/redirect config routes the same "Gateway configuration rejected:" wording as other config errors
- Serve index.html with the real package.json version baked into the cache-busting query string instead of a stale hardcoded one
- Bump version to 0.11.31
This commit is contained in:
marvin
2026-09-12 15:45:34 -04:00
parent 15dbfb4971
commit 815769c539
4 changed files with 79 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "site-gateway", "name": "site-gateway",
"version": "0.11.30", "version": "0.11.31",
"private": true, "private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module", "type": "module",
+9
View File
@@ -442,6 +442,15 @@ $("#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; }
}); });
window.addEventListener("hashchange", () => {
if (!state.user) return; // Not logged in yet; boot() handles initial routing.
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`);
state.view = location.hash.slice(1) || "overview";
render();
loadFeatureView().catch(error => toast(error.message));
});
boot().catch(error => toast(error.message)); boot().catch(error => toast(error.message));
function syncUpstreamTlsControls(form) { function syncUpstreamTlsControls(form) {
+3 -1
View File
@@ -113,6 +113,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
#backup-settings-form select{appearance:none!important} #backup-settings-form select{appearance:none!important}
.settings-form .form-section{grid-column:1/-1;padding:4px 0 20px;border-bottom:1px solid var(--line)}.settings-form .form-section+.form-section{padding-top:20px}.settings-form .form-section:last-of-type{border-bottom:0}.settings-form .form-section .eyebrow{margin-bottom:12px}.settings-form .form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px}.settings-form .form-grid .check-control{align-self:end}.settings-form .form-section-wide .check-control{max-width:420px}.backup-settings .dialog-actions{grid-column:1/-1}.backup-settings code{color:var(--green);font-family:ui-monospace,monospace;font-size:.78rem} .settings-form .form-section{grid-column:1/-1;padding:4px 0 20px;border-bottom:1px solid var(--line)}.settings-form .form-section+.form-section{padding-top:20px}.settings-form .form-section:last-of-type{border-bottom:0}.settings-form .form-section .eyebrow{margin-bottom:12px}.settings-form .form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px}.settings-form .form-grid .check-control{align-self:end}.settings-form .form-section-wide .check-control{max-width:420px}.backup-settings .dialog-actions{grid-column:1/-1}.backup-settings code{color:var(--green);font-family:ui-monospace,monospace;font-size:.78rem}
#backup-settings-form select,#backup-settings-form input[type="number"],#backup-settings-form input[type="password"]{display:block;width:100%;height:44px;min-height:44px;margin-top:7px;padding:0 12px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);line-height:42px}#backup-settings-form select{appearance:auto}#backup-settings-form .check-control{display:flex;align-items:center;height:44px;min-height:44px;margin-top:16px;padding:0 12px}#backup-settings-form .check-control input{width:17px;height:17px;margin:0;line-height:normal}#backup-settings-form .dialog-actions{margin-top:18px;padding-top:16px} #backup-settings-form select,#backup-settings-form input[type="number"],#backup-settings-form input[type="password"]{display:block;width:100%;height:44px;min-height:44px;margin-top:7px;padding:0 12px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);line-height:42px}#backup-settings-form select{appearance:auto}#backup-settings-form .check-control{display:flex;align-items:center;height:44px;min-height:44px;margin-top:16px;padding:0 12px}#backup-settings-form .check-control input{width:17px;height:17px;margin:0;line-height:normal}#backup-settings-form .dialog-actions{margin-top:18px;padding-top:16px}
:root[data-theme="light"] #backup-settings-form select,:root[data-theme="light"] #backup-settings-form input[type="number"],:root[data-theme="light"] #backup-settings-form input[type="password"]{background:#fff}
.backup-password-field .field-label{display:flex;align-items:center;justify-content:space-between;gap:10px}.backup-password-field .optional{float:none}.backup-password-field small{display:block;max-width:38rem;line-height:1.45} .backup-password-field .field-label{display:flex;align-items:center;justify-content:space-between;gap:10px}.backup-password-field .optional{float:none}.backup-password-field small{display:block;max-width:38rem;line-height:1.45}
.event-filters{display:flex;gap:10px;margin:4px 0 15px}.event-filters label{min-width:180px;margin:0}.event-filters select{width:100%;height:44px;min-height:44px}.event-list{max-height:360px;overflow:auto}.event-row{display:flex;align-items:flex-start;gap:12px;padding:12px 3px;border-top:1px solid var(--line)}.event-row:first-child{border-top:0}.event-row>span:last-child{display:grid;gap:3px}.event-row small{text-transform:capitalize;color:var(--muted);font-size:.7rem}.activity-mark.warn{background:rgba(255,191,105,.12);color:var(--warning)}@media(max-width:600px){.event-filters{flex-direction:column}.event-filters label{min-width:0}} .event-filters{display:flex;gap:10px;margin:4px 0 15px}.event-filters label{min-width:180px;margin:0}.event-filters select{width:100%;height:44px;min-height:44px}.event-list{max-height:360px;overflow:auto}.event-row{display:flex;align-items:flex-start;gap:12px;padding:12px 3px;border-top:1px solid var(--line)}.event-row:first-child{border-top:0}.event-row>span:last-child{display:grid;gap:3px}.event-row small{text-transform:capitalize;color:var(--muted);font-size:.7rem}.activity-mark.warn{background:rgba(255,191,105,.12);color:var(--warning)}@media(max-width:600px){.event-filters{flex-direction:column}.event-filters label{min-width:0}}
.dashboard-panel .panel-heading .text-button{white-space:nowrap;font-size:.75rem} .dashboard-panel .panel-heading .text-button{white-space:nowrap;font-size:.75rem}
@@ -129,7 +130,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
#access-assignment-summary>strong{margin-top:20px;padding-top:16px;border-top:1px solid var(--line)}.access-create-guidance{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--panel2)}.access-create-guidance strong{display:block}.access-create-guidance p{margin:6px 0 0;color:var(--muted);line-height:1.45} #access-assignment-summary>strong{margin-top:20px;padding-top:16px;border-top:1px solid var(--line)}.access-create-guidance{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--panel2)}.access-create-guidance strong{display:block}.access-create-guidance p{margin:6px 0 0;color:var(--muted);line-height:1.45}
.danger-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.danger-actions .button{margin:0} .danger-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.danger-actions .button{margin:0}
.docs-intro{margin-bottom:22px;padding:24px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95))}.docs-intro h2{margin:0 0 10px}.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6} .docs-intro{--text:#f4f7fb;--muted:#aebbd0;margin-bottom:22px;padding:24px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));color:var(--text)}.docs-intro h2{margin:0 0 10px}.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6}
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(16,26,43,.9)}.docs-nav .eyebrow{margin:4px 8px 8px}.docs-nav button{padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font:inherit}.docs-nav button:hover{background:rgba(98,230,167,.1);color:var(--text)}.docs #docs-content{display:grid;gap:16px}.docs #docs-content article{scroll-margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76);line-height:1.6}.docs #docs-content article h2{margin:0 0 12px}.docs #docs-content article h3{margin:20px 0 5px;color:var(--green);font-size:.86rem}.docs #docs-content article p{color:var(--muted)}.docs .doc-search{display:block;margin-top:18px}.docs .doc-search input{margin-top:8px}@media(max-width:800px){.docs-layout{grid-template-columns:1fr}.docs-nav{position:static;display:flex;flex-wrap:wrap}.docs-nav .eyebrow{width:100%}} .docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(16,26,43,.9)}.docs-nav .eyebrow{margin:4px 8px 8px}.docs-nav button{padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font:inherit}.docs-nav button:hover{background:rgba(98,230,167,.1);color:var(--text)}.docs #docs-content{display:grid;gap:16px}.docs #docs-content article{scroll-margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76);line-height:1.6}.docs #docs-content article h2{margin:0 0 12px}.docs #docs-content article h3{margin:20px 0 5px;color:var(--green);font-size:.86rem}.docs #docs-content article p{color:var(--muted)}.docs .doc-search{display:block;margin-top:18px}.docs .doc-search input{margin-top:8px}@media(max-width:800px){.docs-layout{grid-template-columns:1fr}.docs-nav{position:static;display:flex;flex-wrap:wrap}.docs-nav .eyebrow{width:100%}}
.docs-intro{padding:32px 34px}.docs-search-panel{max-width:880px;margin-top:24px;padding:18px 20px;border:1px solid var(--line);border-radius:14px;background:rgba(7,15,28,.42)}.docs-search-panel .doc-search{margin:0}.docs-search-panel .doc-search span{display:block;margin-bottom:8px;font-weight:750;color:var(--text)}.docs-search-panel small{display:block;margin-top:8px;color:var(--muted)}.docs #docs-content article ul{margin:10px 0 0;padding-left:22px;color:var(--muted)}.docs #docs-content article li{margin:8px 0} .docs-intro{padding:32px 34px}.docs-search-panel{max-width:880px;margin-top:24px;padding:18px 20px;border:1px solid var(--line);border-radius:14px;background:rgba(7,15,28,.42)}.docs-search-panel .doc-search{margin:0}.docs-search-panel .doc-search span{display:block;margin-bottom:8px;font-weight:750;color:var(--text)}.docs-search-panel small{display:block;margin-top:8px;color:var(--muted)}.docs #docs-content article ul{margin:10px 0 0;padding-left:22px;color:var(--muted)}.docs #docs-content article li{margin:8px 0}
.docs-intro{width:100%;box-sizing:border-box;text-align:center}.docs-intro>p:not(.eyebrow){margin-left:auto;margin-right:auto}.docs-search-panel{max-width:none;text-align:left}.docs-search-panel .doc-search input{width:100%;box-sizing:border-box} .docs-intro{width:100%;box-sizing:border-box;text-align:center}.docs-intro>p:not(.eyebrow){margin-left:auto;margin-right:auto}.docs-search-panel{max-width:none;text-align:left}.docs-search-panel .doc-search input{width:100%;box-sizing:border-box}
@@ -169,6 +170,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
.backup-settings .encryption-toggle{height:auto!important;min-height:0!important;margin-top:16px!important;padding:0!important;background:transparent!important;border:0!important;display:block!important} .backup-settings .encryption-toggle{height:auto!important;min-height:0!important;margin-top:16px!important;padding:0!important;background:transparent!important;border:0!important;display:block!important}
.backup-settings .encryption-toggle .field-label{display:flex;align-items:center;height:20px;margin:0 0 7px;color:#c7d0de;font-size:.82rem;font-weight:650} .backup-settings .encryption-toggle .field-label{display:flex;align-items:center;height:20px;margin:0 0 7px;color:#c7d0de;font-size:.82rem;font-weight:650}
.backup-settings .encryption-toggle-box{display:flex;align-items:center;gap:10px;height:44px;min-height:44px;padding:0 12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;box-sizing:border-box} .backup-settings .encryption-toggle-box{display:flex;align-items:center;gap:10px;height:44px;min-height:44px;padding:0 12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;box-sizing:border-box}
:root[data-theme="light"] .backup-settings .encryption-toggle-box{background:#fff}
.backup-settings .encryption-toggle-box input{width:17px!important;height:17px!important;margin:0!important;flex:0 0 auto} .backup-settings .encryption-toggle-box input{width:17px!important;height:17px!important;margin:0!important;flex:0 0 auto}
.backup-settings .encryption-toggle-box>span{color:var(--muted);font-size:.72rem;line-height:1.35} .backup-settings .encryption-toggle-box>span{color:var(--muted);font-size:.72rem;line-height:1.35}
/* Authoritative backup-form field layout: labels sit above equal-height controls. */ /* Authoritative backup-form field layout: labels sit above equal-height controls. */
+66 -8
View File
@@ -469,6 +469,24 @@ async function certificateInventory() {
return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates }; return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates };
} }
async function pruneOrphanedCertificates(candidateDomains) {
const domains = [...new Set((candidateDomains || []).filter(Boolean).map(domain => String(domain).toLowerCase()))];
if (!domains.length) return;
const stillInUse = new Set([...sites, ...proxies, ...redirects].filter(item => item.enabled).flatMap(item => normalizeDomains(item.domain, item.domains)).map(domain => domain.toLowerCase()));
const orphaned = domains.filter(domain => !stillInUse.has(domain));
if (!orphaned.length) return;
const files = await walkFiles(certificateDir).catch(() => []);
const removed = new Set();
for (const file of files) {
const directory = path.dirname(file);
if (orphaned.includes(path.basename(directory).toLowerCase()) && !removed.has(directory)) {
await fsp.rm(directory, { recursive: true, force: true }).catch(() => {});
removed.add(directory);
}
}
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();
@@ -510,6 +528,22 @@ async function checkAllProxies() {
return proxies.map(publicProxy); return proxies.map(publicProxy);
} }
const SENSITIVE_QUERY_PARAM_PATTERNS = [/token/i, /secret/i, /password/i, /passwd/i, /auth/i, /session/i, /api[-_]?key/i, /credential/i];
function redactUri(uri) {
const str = String(uri || "");
const queryIndex = str.indexOf("?");
if (queryIndex === -1) return str;
const pathPart = str.slice(0, queryIndex);
let params;
try { params = new URLSearchParams(str.slice(queryIndex + 1)); } catch { return `${pathPart}?REDACTED`; }
let redactedAny = false;
for (const name of [...params.keys()]) {
if (SENSITIVE_QUERY_PARAM_PATTERNS.some(pattern => pattern.test(name))) { params.set(name, "REDACTED"); redactedAny = true; }
}
return redactedAny ? `${pathPart}?${params.toString()}` : str;
}
async function readAccessLogs(limit = 100, host = "") { async function readAccessLogs(limit = 100, host = "") {
const files = (await fsp.readdir(logsDir).catch(() => [])).filter(name => name === "access.json" || name.startsWith("access.json.")).sort().reverse(); const files = (await fsp.readdir(logsDir).catch(() => [])).filter(name => name === "access.json" || name.startsWith("access.json.")).sort().reverse();
const entries = []; const entries = [];
@@ -519,7 +553,7 @@ async function readAccessLogs(limit = 100, host = "") {
try { try {
const raw = JSON.parse(line); const request = raw.request || {}; const requestHost = String(request.host || "").split(":")[0]; const raw = JSON.parse(line); const request = raw.request || {}; const requestHost = String(request.host || "").split(":")[0];
if (host && requestHost !== host) continue; if (host && requestHost !== host) continue;
entries.push({ at: raw.ts ? new Date(raw.ts * 1000).toISOString() : null, host: requestHost, method: request.method, uri: request.uri, status: raw.status, size: raw.size, durationMs: Number.isFinite(raw.duration) ? Math.round(raw.duration * 1000) : null, remoteIp: request.remote_ip || null }); entries.push({ at: raw.ts ? new Date(raw.ts * 1000).toISOString() : null, host: requestHost, method: request.method, uri: redactUri(request.uri), status: raw.status, size: raw.size, durationMs: Number.isFinite(raw.duration) ? Math.round(raw.duration * 1000) : null, remoteIp: request.remote_ip || null });
if (entries.length >= limit) return entries; if (entries.length >= limit) return entries;
} catch { /* Skip incomplete lines while Caddy writes. */ } } catch { /* Skip incomplete lines while Caddy writes. */ }
} }
@@ -848,6 +882,11 @@ const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024
app.disable("x-powered-by"); app.disable("x-powered-by");
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) => {
const html = fs.readFileSync(path.join(publicDir, "index.html"), "utf8")
.replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`);
res.type("html").send(html);
});
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'") }));
@@ -1055,6 +1094,9 @@ app.get("/api/icons/search", async (req, res, next) => {
res.json(results); res.json(results);
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
function entryLabel(item) {
return item?.name || item?.displayName || item?.username || "item";
}
app.put("/api/:kind/:id/icon", async (req, res, next) => { app.put("/api/:kind/:id/icon", async (req, res, next) => {
try { try {
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
@@ -1066,7 +1108,7 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." }); if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
item.iconSlug = null; item.icon = url; item.iconSlug = null; item.icon = url;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
recordActivity(`Icon URL updated for “${item.name}”.`); recordActivity(`Icon URL updated for “${entryLabel(item)}”.`);
return res.json(item); return res.json(item);
} }
const slug = String(req.body.slug || "").trim(); const slug = String(req.body.slug || "").trim();
@@ -1074,7 +1116,7 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
item.iconSlug = slug || null; item.iconSlug = slug || null;
item.icon = icon; item.icon = icon;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else await saveAccessLists(); if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else await saveAccessLists();
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${item.name}”.`); recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(item)}”.`);
res.json(item); res.json(item);
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -1091,7 +1133,7 @@ app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next
await fsp.rename(req.file.path, path.join(iconsDir, filename)); await fsp.rename(req.file.path, path.join(iconsDir, filename));
item.iconSlug = null; item.icon = `/site-icons/${filename}`; item.iconSlug = null; item.icon = `/site-icons/${filename}`;
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
recordActivity(`Custom icon uploaded for “${item.name}”.`); recordActivity(`Custom icon uploaded for “${entryLabel(item)}”.`);
res.json(item); res.json(item);
} 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(() => {}); }
@@ -1157,6 +1199,7 @@ app.delete("/api/sites/:id", async (req, res, next) => {
await stopSite(site.id); await stopSite(site.id);
await syncCaddy(); await syncCaddy();
await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true });
await pruneOrphanedCertificates(normalizeDomains(site.domain, site.domains));
await saveSites(); await saveSites();
recordActivity(`Hosted site “${site.name}” deleted.`); recordActivity(`Hosted site “${site.name}” deleted.`);
res.status(204).end(); res.status(204).end();
@@ -1166,6 +1209,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
try { try {
const site = sites.find(item => item.id === req.params.id); const site = sites.find(item => item.id === req.params.id);
if (!site) return res.status(404).json({ error: "Site not found." }); if (!site) return res.status(404).json({ error: "Site not found." });
const previousDomains = normalizeDomains(site.domain, site.domains);
const domain = normalizeDomain(req.body.domain); const domain = normalizeDomain(req.body.domain);
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
const domainError = validateDomains(domains, site.id); const domainError = validateDomains(domains, site.id);
@@ -1177,6 +1221,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
if (site.healthEnabled === false) upstreamHealth.set(site.id, { status: "unmonitored", checkedAt: null, history: [] }); if (site.healthEnabled === false) upstreamHealth.set(site.id, { status: "unmonitored", checkedAt: null, history: [] });
else { upstreamHealth.set(site.id, { status: "pending", checkedAt: null, history: [] }); checkProxy({ ...site, target: `http://127.0.0.1:${site.port}` }).catch(error => console.warn("Hosted site health check failed:", error.message)); } else { upstreamHealth.set(site.id, { status: "pending", checkedAt: null, history: [] }); checkProxy({ ...site, target: `http://127.0.0.1:${site.port}` }).catch(error => console.warn("Hosted site health check failed:", error.message)); }
await syncCaddy(); await syncCaddy();
await pruneOrphanedCertificates(previousDomains);
await saveSites(); await saveSites();
recordActivity(`Gateway settings updated for “${site.name}”.`); recordActivity(`Gateway settings updated for “${site.name}”.`);
res.json(publicSite(site)); res.json(publicSite(site));
@@ -1214,6 +1259,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
try { try {
const proxy = proxies.find(item => item.id === req.params.id); const proxy = proxies.find(item => item.id === req.params.id);
if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
if (req.body.domain !== undefined) { if (req.body.domain !== undefined) {
const domain = normalizeDomain(req.body.domain); const domain = normalizeDomain(req.body.domain);
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
@@ -1232,6 +1278,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
if (req.body.hsts !== undefined) proxy.hsts = req.body.hsts === true; if (req.body.hsts !== undefined) proxy.hsts = req.body.hsts === true;
applyAdvancedSettings(proxy, req.body); applyAdvancedSettings(proxy, req.body);
await syncCaddy(); await syncCaddy();
await pruneOrphanedCertificates(previousDomains);
await saveProxies(); await saveProxies();
recordActivity(`Proxy host “${proxy.name}” updated.`); recordActivity(`Proxy host “${proxy.name}” updated.`);
res.json(publicProxy(proxy)); res.json(publicProxy(proxy));
@@ -1272,6 +1319,8 @@ app.delete("/api/proxies/:id", async (req, res, next) => {
if (index < 0) return res.status(404).json({ error: "Proxy host not found." }); if (index < 0) return res.status(404).json({ error: "Proxy host not found." });
const [proxy] = proxies.splice(index, 1); const [proxy] = proxies.splice(index, 1);
await syncCaddy(); await syncCaddy();
await fsp.rm(path.join(customCertificatesDir, proxy.id), { recursive: true, force: true }).catch(() => {});
await pruneOrphanedCertificates(normalizeDomains(proxy.domain, proxy.domains));
await saveProxies(); await saveProxies();
recordActivity(`Proxy host “${proxy.name}” deleted.`); recordActivity(`Proxy host “${proxy.name}” deleted.`);
res.status(204).end(); res.status(204).end();
@@ -1355,6 +1404,7 @@ app.post("/api/redirects", async (req, res, next) => {
app.patch("/api/redirects/:id", async (req, res, next) => { app.patch("/api/redirects/:id", async (req, res, next) => {
try { try {
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." }); const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
const previousDomains = normalizeDomains(item.domain, item.domains);
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; } if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim(); for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
@@ -1363,11 +1413,11 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath); if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls; if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts); if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
await syncCaddy(); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item); await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item);
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
app.delete("/api/redirects/:id", async (req, res, next) => { 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 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); }
}); });
app.patch("/api/settings", async (req, res, next) => { app.patch("/api/settings", async (req, res, next) => {
@@ -1419,10 +1469,18 @@ 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|access-lists)(\/|$)/i;
app.use((error, req, res, next) => { app.use((error, req, res, next) => {
console.error(error); console.error(error);
recordActivity(`${req.method} ${req.path}: ${humanizeGatewayActivityError(error.message)}`, "error"); const rawMessage = error.message || "Something went wrong.";
res.status(error.status || 500).json({ error: error.message || "Something went wrong." }); const isConfigRoute = GATEWAY_CONFIG_ROUTE.test(req.path) && ["PATCH", "POST", "DELETE", "PUT"].includes(req.method);
let logMessage = rawMessage;
if (isConfigRoute) {
const humanized = humanizeGatewayActivityError(rawMessage);
logMessage = /^Gateway configuration rejected:/i.test(humanized) ? humanized : `Gateway configuration rejected: ${humanized}`;
}
recordActivity(`${req.method} ${req.path}: ${logMessage}`, "error");
res.status(error.status || 500).json({ error: rawMessage });
}); });
app.listen(adminPort, "0.0.0.0", () => { app.listen(adminPort, "0.0.0.0", () => {