From 815769c5397c572dffcd98390745f4de0a5e42d9 Mon Sep 17 00:00:00 2001 From: marvin Date: Sat, 12 Sep 2026 15:45:34 -0400 Subject: [PATCH] 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 --- package.json | 2 +- src/public/app.js | 9 ++++++ src/public/styles.css | 4 ++- src/server.js | 74 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 63b5f57..f2c1609 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.30", + "version": "0.11.31", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index 0f0921b..ab39a82 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -442,6 +442,15 @@ $("#password-form").addEventListener("submit", async event => { } catch (error) { $("#password-error").textContent = error.message; } 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)); function syncUpstreamTlsControls(form) { diff --git a/src/public/styles.css b/src/public/styles.css index ae64d89..a4951c6 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -113,6 +113,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto} #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} #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} .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} @@ -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} .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-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} @@ -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 .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} +: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>span{color:var(--muted);font-size:.72rem;line-height:1.35} /* Authoritative backup-form field layout: labels sit above equal-height controls. */ diff --git a/src/server.js b/src/server.js index 284c764..6080743 100644 --- a/src/server.js +++ b/src/server.js @@ -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 }; } +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() { 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(); @@ -510,6 +528,22 @@ async function checkAllProxies() { 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 = "") { const files = (await fsp.readdir(logsDir).catch(() => [])).filter(name => name === "access.json" || name.startsWith("access.json.")).sort().reverse(); const entries = []; @@ -519,7 +553,7 @@ async function readAccessLogs(limit = 100, host = "") { try { const raw = JSON.parse(line); const request = raw.request || {}; const requestHost = String(request.host || "").split(":")[0]; 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; } 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.use(express.json()); app.use(express.urlencoded({ extended: false })); +app.get(["/", "/index.html"], (req, res) => { + const html = fs.readFileSync(path.join(publicDir, "index.html"), "utf8") + .replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`); + res.type("html").send(html); +}); 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'") })); @@ -1055,6 +1094,9 @@ app.get("/api/icons/search", async (req, res, next) => { res.json(results); } 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) => { 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; @@ -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." }); 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(); - recordActivity(`Icon URL updated for “${item.name}”.`); + recordActivity(`Icon URL updated for “${entryLabel(item)}”.`); return res.json(item); } 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.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(); - recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${item.name}”.`); + recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(item)}”.`); res.json(item); } 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)); 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(); - recordActivity(`Custom icon uploaded for “${item.name}”.`); + recordActivity(`Custom icon uploaded for “${entryLabel(item)}”.`); res.json(item); } catch (error) { next(error); } 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 syncCaddy(); await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); + await pruneOrphanedCertificates(normalizeDomains(site.domain, site.domains)); await saveSites(); recordActivity(`Hosted site “${site.name}” deleted.`); res.status(204).end(); @@ -1166,6 +1209,7 @@ app.patch("/api/sites/:id", async (req, res, next) => { try { const site = sites.find(item => item.id === req.params.id); 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 domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains); 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: [] }); 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 pruneOrphanedCertificates(previousDomains); await saveSites(); recordActivity(`Gateway settings updated for “${site.name}”.`); res.json(publicSite(site)); @@ -1214,6 +1259,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => { try { const proxy = proxies.find(item => item.id === req.params.id); if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); + const previousDomains = normalizeDomains(proxy.domain, proxy.domains); if (req.body.domain !== undefined) { const domain = normalizeDomain(req.body.domain); 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; applyAdvancedSettings(proxy, req.body); await syncCaddy(); + await pruneOrphanedCertificates(previousDomains); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” updated.`); 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." }); const [proxy] = proxies.splice(index, 1); await syncCaddy(); + await fsp.rm(path.join(customCertificatesDir, proxy.id), { recursive: true, force: true }).catch(() => {}); + await pruneOrphanedCertificates(normalizeDomains(proxy.domain, proxy.domains)); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” deleted.`); 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) => { try { 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.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(); @@ -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.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); - 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); } }); 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) => { @@ -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); } }); 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) => { console.error(error); - recordActivity(`${req.method} ${req.path}: ${humanizeGatewayActivityError(error.message)}`, "error"); - res.status(error.status || 500).json({ error: error.message || "Something went wrong." }); + const rawMessage = 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", () => {