Compare commits

...

2 Commits

11 changed files with 915 additions and 85 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.14.0-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.15.1-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+23 -6
View File
@@ -6,15 +6,13 @@
`v0.13.0` is a real batch under that same convention, not a targeted fix, even though none of it changes what the app *does*: the in-app light theme has been removed entirely (the app is dark-only now, including the two visitor-facing themed pages -- the default-site 404/welcome/custom-HTML page and the Access-List sign-in page, both previously following the visitor's OS light/dark preference and now fixed dark for consistency with the rest of the app), the full color/spacing/radius design-token system begun in v0.12.0 has been completed (zero hardcoded color literals remain anywhere outside the token definitions), and `styles.css` has been restructured into commented, page-aligned sections matching the convention already used in `app.js`/`features.js`/`server.js`. A handful of small pre-existing bugs (a duplicate CSS custom property, some dead/duplicate rules, a decorative background glow that rendered incorrectly at certain aspect ratios) were also found and fixed along the way.
`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup, and scopes out (but does not yet ship) a larger set of previously-discussed features tracked below as follow-up work.
`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup.
- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list and a "Resync now" callout appears under Administration \u2192 Default site, both driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list, driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
- **Backup-encryption readiness** — the "Encrypt scheduled backups" checkbox no longer lets you configure something that will silently fail later. `/api/config` now reports whether the `BACKUP_PASSWORD` environment variable is actually set; the checkbox is disabled with an explanatory message when it isn't, and if it was previously saved as enabled and `BACKUP_PASSWORD` has since been removed, it shows a distinct warning instead of failing quietly at the next scheduled run.
- **Dialog cleanup** \u2014 every themed popout dialog's redundant "\u00d7" close button (in the dialog-heading row) has been removed app-wide; each dialog already has a working Cancel/Close button in its actions row, so this is pure de-duplication with no loss of function. New dialogs are expected to follow this pattern going forward.
### Follow-up work carried from this release's planning
A larger feature set was scoped for `v0.14.0` and intentionally deferred rather than shipped partially-verified. These remain on the roadmap for a future release:
`v0.15.0` ships the full set of features scoped alongside `v0.14.0` and deferred at the time — nothing here was cut:
- **REST API with issuable tokens** \u2014 admin-issued bearer tokens (full or read-only scope) for scripting against the Site Gateway API outside the browser session, bound to the issuing user's session version so a password reset/deactivation revokes them automatically.
- **Backup/restore history** \u2014 a durable, database-backed history of every backup, restore, and deletion (including failed attempts), shown as a human-readable timeline that never displays raw backup filenames.
@@ -22,6 +20,19 @@ A larger feature set was scoped for `v0.14.0` and intentionally deferred rather
- **"View Caddy config" popout** \u2014 a read-only, prettified view of the exact Caddy configuration block generated for a given site, proxy, or redirect, built from the same code path that generates the real deployed config so it can never drift from it.
- **Performance screen overhaul** \u2014 clock-aligned time-axis labels, a y-axis unit, hover tooltips with error counts, p95 latency, bandwidth and unique-visitor columns, a 4xx/5xx-colored error breakdown, top-paths-per-host, and a slowest-requests panel.
- **Dashboard tile color unification** \u2014 normalizing all "normal count" tiles to a shared green baseline that reacts to warning/danger states the same way the existing Needs Attention tile does.
- Two pre-existing bugs found and fixed along the way: `sessionVersion` was never actually rotated anywhere, meaning a password change, MFA disable, or admin-forced deactivation didn't invalidate existing sessions/API tokens as documented; and the redirect card's "Change icon" menu action was silently falling through to the enable/disable toggle handler instead of opening the icon picker.
- Also folds in the config-drift attention-tile click-through fix from `v0.14.1` (never separately released): clicking the dashboard's "Configuration drift" item now goes to Administration \u2192 Gateway Defaults, not the generic Administration landing tab.
`v0.15.1` is a fix-list batch from live testing of `v0.15.0`, not new features:
- Native `<select>` dropdowns (Performance's Range picker and ~36 others app-wide) now render in the app's dark theme instead of the browser's light default \u2014 root cause was a missing `color-scheme` meta tag, already present on the other two themed pages but never added to the main app shell.
- The Live Health dashboard panel's badge/border now derive only from its own 6 displayed checks (gateway, HTTP, HTTPS, storage, streaming ports, upstreams) instead of the site-wide Needs Attention count, so an unrelated issue (a certificate warning, a site error) no longer turns the whole panel red.
- Hosted-site, proxy, and upstream-health attention items are now clickable, linking to the Hosted/Proxies list — previously only certificate and drift items had a click target.
- The Configuration drift attention tile now has its own inline "Resync now" button, instead of requiring a click-through to Gateway Defaults to find the same action.
- Fixed a false-positive drift bug: the drift check compared `JSON.stringify()` output directly, which is sensitive to key order — two semantically identical configs could register as "drifted" solely because Caddy serialized their keys differently. Replaced with an order-independent comparison. Drift detection now also logs a Gateway Events entry on first detection (not on every repeated check), so a future report of drift reappearing can be confirmed against a timestamp instead of guesswork.
- Toast notifications no longer render hidden/blurred behind an open dialog (missing `z-index`, and an open `<dialog>` renders above normal page content by default).
- The Top Paths popout now states it's showing the top 10, matching the existing server-side cap.
- The Runtime/System dashboard panel's top accent bar changed from a stray `--blue` token to `--green`, matching the default accent already used by every other dashboard tile.
## Product direction
@@ -44,6 +55,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it, plus an administrator-side override (Administration → Users → “•••” → Disable 2FA) for a user who's locked out with no recovery codes left. Logged to the Audit log.
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
- REST API with admin-issued bearer tokens (full or read-only scope), bound to the issuing users session version so a password reset or deactivation revokes them automatically.
### Certificates and TLS
@@ -55,7 +67,10 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
- Performance view with request throughput, response times, and per-route breakdowns — the host filter applies to the throughput table as well as the trend chart, average response times display in seconds once they pass 1000ms, and per-domain error counts open a themed breakdown by status code.
- Performance view with request throughput, response times, per-route breakdowns, p95 latency, bandwidth, and unique-visitor columns, a 4xx/5xx-colored error breakdown, a top-10-paths-per-host popout, and a slowest-requests panel — the host filter applies to the throughput table as well as the trend chart, and average response times display in seconds once they pass 1000ms.
- A read-only "View Caddy config" popout on Hosted Sites, Proxy Hosts, and Redirect Hosts, showing the exact Caddyfile block generated for that route, built from the same code path that generates the real deployed config so it can never drift from whats shown.
- Dashboard tile colors are unified around a shared green baseline that reacts to warning/danger states, matching the existing Needs Attention tiles behavior.
- Configuration drift detection compares Caddys live configuration against the saved routes every 10 minutes, flags a Needs Attention item with a one-click inline "Resync now" action, and logs a Gateway Events entry the first time drift is detected.
- Rotating access and activity logs.
- Update-available banner when a newer image is deployed.
- A redacted support-report export exists (version, config health, certificate readiness, upstream checks, recent events) but its UI entry point is currently hidden pending a readability rewrite of the report's output format.
@@ -65,6 +80,8 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- Built-in SQLite persistence at `/data/database/site-gateway.sqlite` — no external database container.
- Configuration and Complete backups, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted; pre-restore safety backups and configuration validation before activation.
- PUID/PGID-aware startup for Unraid and ZimaOS-style permission models.
- A durable, database-backed history of every backup, restore, and deletion attempt, shown as a human-readable timeline.
- An opt-in Docker container picker (gated on the Docker socket being mounted and readable) for choosing Proxy/Streaming targets from the hosts running containers instead of typing them by hand.
### Brand and docs
+6
View File
@@ -32,5 +32,11 @@ services:
# - "25565:25565/udp"
volumes:
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
# Optional: enables "Pick from running containers" for Proxy and Streaming
# host targets (Administration > Gateway defaults > Docker container selection).
# Read-only, but be deliberate: access to the Docker socket is effectively root
# on the host -- anything that can talk to it can start privileged containers and
# mount the host filesystem. Leave this commented out unless you want the feature.
# - /var/run/docker.sock:/var/run/docker.sock:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
+6
View File
@@ -56,3 +56,9 @@ services:
# - "25565:25565/udp"
volumes:
- ./data:/data
# Optional: enables "Pick from running containers" for Proxy and Streaming
# host targets (Administration > Gateway defaults > Docker container selection).
# Read-only, but be deliberate: access to the Docker socket is effectively root
# on the host -- anything that can talk to it can start privileged containers and
# mount the host filesystem. Leave this commented out unless you want the feature.
# - /var/run/docker.sock:/var/run/docker.sock:ro
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.14.0",
"version": "0.15.1",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+180 -15
View File
@@ -8,7 +8,7 @@
// --- Shared DOM shortcut and app state ----------------------------------------
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, performanceErrorBreakdowns: {} };
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, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] };
// 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).
@@ -148,6 +148,33 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
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); }
// Dashboard tiles share one baseline accent (green) and switch to the existing
// --warning / --danger tokens when the thing they count is actually in trouble --
// the same mechanism the "Needs attention" chip already used.
const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"];
function setTileAccent(valueId, level) {
const tile = $(valueId)?.closest(".metric-card, .metric-chip");
if (!tile) return;
tile.classList.remove(...TILE_ACCENT_CLASSES);
tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green");
}
function applyTileAccents(data) {
const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning";
setTileAccent("#dash-hosted-total", group(data.hosted));
const upstreams = data.upstreams || { total: 0, unhealthy: 0 };
const proxyLevel = group(data.proxies);
setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green");
const certificates = data.certificates || {};
const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green";
setTileAccent("#dash-tls-total", certificateLevel);
// Redirect hosts have no runtime failure state of their own, so they stay on the baseline.
setTileAccent("#dash-redirect-total", "green");
const streaming = data.streamingPorts || { total: 0, listening: 0 };
setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning");
// Throughput is a rate, not a health signal: there is no "bad" value to react to.
setTileAccent("#dash-throughput-total", "green");
}
function renderDashboard() {
const data = state.dashboard; if (!data) return;
if (data.system) renderDashboardJobsSafe(data.system);
@@ -161,11 +188,13 @@ function renderDashboard() {
$("#dash-stream-total").textContent = state.streams?.length || 0;
$("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
applyTileAccents(data);
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const panelStreaming = data.streamingPorts || { total: 0, listening: 0 }, panelUpstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 };
const hasErrors = data.gateway.status === "error" || data.services.http.status === "error" || data.services.https.status === "error" || !data.services.storage.healthy || (panelStreaming.total > 0 && panelStreaming.listening !== panelStreaming.total) || (panelUpstreams.total > 0 && panelUpstreams.unhealthy > 0), isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const overall = $("#overall-health");
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
@@ -198,7 +227,10 @@ function renderDashboard() {
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift"
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span><button type="button" class="button secondary" data-drift-resync>Resync now</button></div>`
: `<${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>';
}
@@ -221,14 +253,14 @@ function canAdmin() { return state.user?.role === "administrator"; }
function hostedCard(site) {
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 menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
}
function proxyCard(proxy) {
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
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>`;
@@ -279,6 +311,20 @@ function renderLogs() {
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
// and the per-domain throughput table -----------------------------------------------
// Clock-boundary label spacing per selected range (hours -> minutes between labels).
const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 };
function formatChartTime(value, intervalMinutes) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" });
if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" });
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; }
// Geometry shared by the chart renderer and the hover tooltip.
const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 };
function renderPerformance() {
const data = state.performance; if (!data) return;
const selected = $("#performance-host").value;
@@ -288,9 +334,11 @@ function renderPerformance() {
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
$("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || [];
state.performancePoints = points;
const max = Math.max(1, ...points.map(point => point.count));
const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
const { left, right, top, bottom, width, height } = PERFORMANCE_CHART;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
const yAt = count => top + plotHeight - (count / max) * plotHeight;
@@ -299,16 +347,34 @@ function renderPerformance() {
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
}).join("");
const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const axisLabels = gridFractions.map(fraction => {
const value = Math.round(max * fraction);
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
}).join("");
const firstPoint = points[0], lastPoint = points[points.length - 1];
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
// Time axis: labels land on real clock boundaries scaled to the selected range, and the
// true first and last sample are always labelled so the window's edges stay readable.
const hours = Number($("#performance-range").value) || 6;
const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60;
const intervalMs = intervalMinutes * 60000;
let timeLabels = "";
if (points.length) {
const candidates = new Set([0, points.length - 1]);
const aligned = [];
points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); });
const stride = Math.max(1, Math.ceil(aligned.length / 8));
aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); });
const ordered = [...candidates].sort((a, b) => a - b);
timeLabels = ordered.map(index => {
const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct);
const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid";
return `<span class="time-label${alignment}" style="left:${leftEdge.toFixed(2)}%">${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}</span>`;
}).join("");
}
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
state.performanceCoords = coords;
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
@@ -322,16 +388,61 @@ function renderPerformance() {
const baseline = (top + plotHeight).toFixed(1);
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse id="performance-hover-dot" class="hidden" cx="0" cy="0" rx="3" ry="3" fill="var(--green)" stroke="var(--panel)" stroke-width="1.5" />` : "";
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
hidePerformanceTooltip();
const routes = (data.routes || []).filter(route => !selected || route.host === selected);
state.performanceErrorBreakdowns = {};
state.performanceTopPaths = {};
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; };
const formatAvgMs = ms => ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatAvgMs(route.dayAvgMs)}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `<button type="button" class="count-link-button neutral" data-paths-host="${escapeHtml(route.host)}">View</button>`; };
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`).join("") : '<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
renderSlowestRequests(data.slowest || []);
}
// --- Performance: slowest individual requests -------------------------------------------
function renderSlowestRequests(entries) {
const list = $("#performance-slowest"); if (!list) return;
list.innerHTML = entries.length ? entries.map(entry => `<div class="activity-tile"><span class="slowest-copy"><strong title="${escapeHtml(`${entry.method || ""} ${entry.uri || ""}`)}">${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")}</strong><small>${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))}</small></span><span class="slowest-duration">${escapeHtml(formatLatency(entry.durationMs))}</span></div>`).join("") : '<p class="quiet-state">No timed requests in this window yet.</p>';
}
// --- Performance: hover tooltip on the request-trend chart -------------------------------
function hidePerformanceTooltip() {
$("#performance-tooltip")?.classList.add("hidden");
document.querySelector("#performance-hover-dot")?.classList.add("hidden");
}
function showPerformanceTooltip(event) {
const svg = $("#performance-sparkline"), tooltip = $("#performance-tooltip"), points = state.performancePoints || [], coords = state.performanceCoords || [];
if (!svg || !tooltip || !points.length || !coords.length) return;
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const { left, right, width, height } = PERFORMANCE_CHART;
const plotWidth = width - left - right;
const viewX = ((event.clientX - rect.left) / rect.width) * width;
const fraction = Math.min(1, Math.max(0, (viewX - left) / plotWidth));
const index = Math.min(points.length - 1, Math.max(0, Math.round(fraction * (points.length - 1))));
const point = points[index], coordinate = coords[index];
const pixelX = (coordinate[0] / width) * rect.width;
const pixelY = (coordinate[1] / height) * rect.height;
tooltip.innerHTML = `<strong>${point.count.toLocaleString()} request${point.count === 1 ? "" : "s"}</strong><span class="tooltip-errors${point.errors ? "" : " none"}">${(point.errors || 0).toLocaleString()} error${point.errors === 1 ? "" : "s"}</span><br>${escapeHtml(formatTime(point.at))}`;
tooltip.style.left = `${pixelX}px`;
tooltip.style.top = `${pixelY}px`;
tooltip.classList.remove("hidden");
const dot = document.querySelector("#performance-hover-dot");
if (dot) {
// preserveAspectRatio="none" stretches the viewBox, so compensate to keep the dot round.
dot.setAttribute("cx", coordinate[0].toFixed(1));
dot.setAttribute("cy", coordinate[1].toFixed(1));
dot.setAttribute("rx", (3.5 * (width / rect.width)).toFixed(2));
dot.setAttribute("ry", (3.5 * (height / rect.height)).toFixed(2));
dot.classList.remove("hidden");
}
}
$("#performance-sparkline")?.addEventListener("mousemove", showPerformanceTooltip);
$("#performance-sparkline")?.addEventListener("mouseleave", hidePerformanceTooltip);
// --- Administration > Users view ---------------------------------------------------------
function renderUsers() {
@@ -490,7 +601,7 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
// 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, "error"); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
$("#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, "error")); } });
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { const [view, adminTab] = target.split("/"); state.view = view; if (view === "administration" && adminTab) state.adminTab = adminTab; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
// --- 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"); }); }
@@ -504,8 +615,34 @@ $("#performance-host").addEventListener("change", () => loadFeatureView().catch(
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------
function showErrorBreakdown(host, total, breakdown) { let dialog = document.querySelector("#error-breakdown-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); } const rows = breakdown.map(item => `<div class="error-breakdown-row"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join(""); dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p><div class="error-breakdown-list">${rows}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`; dialog.showModal(); }
$("#performance-rows").addEventListener("click", event => { const button = event.target.closest("[data-error-host]"); if (!button) return; const entry = state.performanceErrorBreakdowns[button.dataset.errorHost]; if (!entry) return; showErrorBreakdown(button.dataset.errorHost, entry.total, entry.breakdown); });
function showErrorBreakdown(host, total, breakdown) {
let dialog = document.querySelector("#error-breakdown-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); }
// Client (4xx) and server (5xx) failures mean very different things, so they are grouped
// and coloured separately instead of appearing as one flat red list.
const client = breakdown.filter(item => Number(item.status) < 500), server = breakdown.filter(item => Number(item.status) >= 500);
const rowsFor = (items, kind) => items.map(item => `<div class="error-breakdown-row ${kind}"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
const clientTotal = client.reduce((sum, item) => sum + item.count, 0), serverTotal = server.reduce((sum, item) => sum + item.count, 0);
const sections = [
client.length ? `<p class="error-breakdown-group">Client errors · 4xx · ${clientTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(client, "client-error")}</div>` : "",
server.length ? `<p class="error-breakdown-group">Server errors · 5xx · ${serverTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(server, "server-error")}</div>` : ""
].join("");
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p>${sections || '<p class="quiet-state">No status codes recorded.</p>'}<div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.showModal();
}
function showTopPaths(host, paths) {
let dialog = document.querySelector("#top-paths-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "top-paths-dialog"; document.body.append(dialog); }
const rows = paths.map(item => `<div class="top-paths-row"><span title="${escapeHtml(item.uri)}">${escapeHtml(item.uri)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">The top 10 most requested paths on this domain in the last 24 hours.</p><div class="top-paths-list">${rows || '<div class="top-paths-row"><span>No requests recorded.</span><span>0</span></div>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.showModal();
}
$("#performance-rows").addEventListener("click", event => {
const errorButton = event.target.closest("[data-error-host]");
if (errorButton) { const entry = state.performanceErrorBreakdowns[errorButton.dataset.errorHost]; if (entry) showErrorBreakdown(errorButton.dataset.errorHost, entry.total, entry.breakdown); return; }
const pathsButton = event.target.closest("[data-paths-host]");
if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
});
$("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
@@ -568,6 +705,7 @@ $("#site-grid").addEventListener("click", async event => {
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
if (action === "icon") openIconPicker(kind, card.dataset.id);
if (action === "caddy-config") openCaddyConfig(kind === "proxy" ? "proxies" : "sites", card.dataset.id);
});
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
@@ -576,8 +714,35 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => {
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; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
if (action === "caddy-config") { closeMenus(); openCaddyConfig("redirects", card.dataset.redirectId); }
});
// --- "View Caddy config" popout --------------------------------------------------------------
// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
const COPY_ICON_SVG = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></svg>';
function caddyConfigDialog() {
let dialog = document.querySelector("#caddy-config-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "caddy-config-dialog"; document.body.append(dialog); }
return dialog;
}
async function openCaddyConfig(kind, id) {
const dialog = caddyConfigDialog();
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Loading…</h2></div></div><p class="muted">Reading the deployed configuration for this route.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>';
if (!dialog.open) dialog.showModal();
try {
const result = await api(`/api/${kind}/${encodeURIComponent(id)}/caddy-config`);
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>${escapeHtml(result.name)}</h2></div><button type="button" class="icon-button" id="copy-caddy-config" aria-label="Copy configuration" title="Copy configuration">${COPY_ICON_SVG}</button></div><p class="muted">This is the exact block Site Gateway writes into the Caddyfile for this route, annotated with what each directive does.</p><pre class="caddy-config-pre">${escapeHtml(result.config)}</pre><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.querySelector("#copy-caddy-config").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(result.config); toast("Configuration copied."); }
catch { toast("Your browser blocked clipboard access.", "error"); }
});
} catch (error) {
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Not available</h2></div></div><p class="muted">${escapeHtml(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
}
}
window.openCaddyConfig = openCaddyConfig;
// --- 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; });
$("#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, "error"); } event.target.value = ""; state.pendingReplace = null; });
+214 -3
View File
@@ -39,7 +39,7 @@ function renderRedirects() {
// The completed response is the only point at which this view should be replaced.
if (!state.loaded) return;
empty.classList.toggle("hidden", !state.loaded || state.redirects.length > 0);
list.innerHTML = state.redirects.map(item => `<article class="site-card redirect-card" data-redirect-id="${item.id}" data-kind="redirect"><div class="card-top"><div class="site-icon">${featureIcon(item,"RD")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Redirect options" aria-expanded="false">•••</button><div class="menu"><button data-redirect-action="edit">Edit redirect host</button><button data-redirect-action="icon">Change icon</button><button data-redirect-action="toggle">${item.enabled ? "Disable" : "Enable"}</button><button data-redirect-action="delete" class="danger-text">Delete redirect host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">${extendedEscape(item.domain)}</p><p class="gateway-address">→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${item.enabled ? "running" : "disabled"}"></span>${item.enabled ? "Running" : "Disabled"}</span><div class="card-actions"><span class="chip">HTTP ${item.code}</span></div></div></article>`).join("");
list.innerHTML = state.redirects.map(item => `<article class="site-card redirect-card" data-redirect-id="${item.id}" data-kind="redirect"><div class="card-top"><div class="site-icon">${featureIcon(item,"RD")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Redirect options" aria-expanded="false">•••</button><div class="menu"><button data-redirect-action="edit">Edit redirect host</button><button data-redirect-action="icon">Change icon</button><button data-redirect-action="caddy-config">View Caddy config</button><button data-redirect-action="toggle">${item.enabled ? "Disable" : "Enable"}</button><button data-redirect-action="delete" class="danger-text">Delete redirect host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">${extendedEscape(item.domain)}</p><p class="gateway-address">→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${item.enabled ? "running" : "disabled"}"></span>${item.enabled ? "Running" : "Disabled"}</span><div class="card-actions"><span class="chip">HTTP ${item.code}</span></div></div></article>`).join("");
}
@@ -193,7 +193,7 @@ window.renderCredentialEditor = renderCredentialEditor;
// Redirect card options menu actions: edit / change icon / delete.
document.querySelector("#redirect-list").addEventListener("click", async event => {
const button = event.target.closest("[data-redirect-action]"), card = button?.closest("[data-redirect-id]"); if (!button || !card) return; const item = state.redirects.find(value => value.id === card.dataset.redirectId); if (!item) return;
try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); }
try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else if (button.dataset.redirectAction === "toggle") await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); else return; await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); }
});
@@ -275,7 +275,7 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); }
document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true);
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
// --- Backup encryption password field: placeholder/visibility polish -------------
@@ -345,3 +345,214 @@ document.addEventListener("click", async event => {
renderConfigDriftCallout();
} catch (error) { toast(error.message); } finally { button.disabled = false; }
});
// ============================================================================================
// v0.15.0 additions: API access tokens, backup history, and the Docker container picker.
// ============================================================================================
// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
const featureCopyIcon = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></svg>';
function featureDialog(id) {
let dialog = document.querySelector(`#${id}`);
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = id; document.body.append(dialog); }
return dialog;
}
// --- Administration > API Access ------------------------------------------------------------
// Follows the renderAuditPanel()/renderRetentionPanel() pattern: the tab and its panel are
// created once, then the table is re-rendered from /api/tokens on demand.
function apiTokenStatus(token) {
if (token.revoked) return { dot: "disabled", label: "Revoked" };
if (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) return { dot: "error", label: "Expired" };
return { dot: "running", label: "Active" };
}
async function loadApiTokens() {
const list = document.querySelector("#api-token-list"); if (!list) return;
try {
const tokens = await api("/api/tokens");
list.innerHTML = tokens.length ? tokens.map(token => {
const status = apiTokenStatus(token);
return `<article class="data-row api-token-row ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><span class="status-dot ${status.dot}"></span><div><strong>${extendedEscape(token.name)}</strong><small>${extendedEscape(status.label)} · ${extendedEscape(token.ownerUsername || "unknown")}</small></div><div><span class="chip api-token-chip">${extendedEscape(token.prefix)}…</span><small>${token.scope === "read-only" ? "Read-only" : "Full access"}</small></div><div><strong>${extendedEscape(formatTime(token.createdAt))}</strong><small>${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</small></div><div class="row-actions">${token.revoked ? "" : '<button class="button secondary danger-text" data-token-action="revoke">Revoke</button>'}</div></article>`;
}).join("") : '<p class="quiet-state padded">No API tokens have been issued yet.</p>';
} catch (error) { list.innerHTML = `<p class="quiet-state padded">${extendedEscape(error.message)}</p>`; }
}
function renderApiTokensPanel() {
if (state.user?.role !== "administrator") return;
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
if (!tabs || !users) return;
let tab = tabs.querySelector('[data-admin-tab="api"]');
if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "api"; tab.textContent = "API Access"; tabs.append(tab); }
let panel = document.querySelector('[data-admin-panel="api"]');
if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "api"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); }
if (panel.dataset.ready) return;
panel.dataset.ready = "1";
panel.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">Programmatic access</p><h2>API access tokens</h2><p class="muted">Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrators password, or disabling their account, revokes every token they issued.</p></div><div class="row-actions"><button id="create-api-token" class="button primary" type="button">Create token</button></div></div><div id="api-token-list" class="data-list"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
tab.addEventListener("click", async () => {
document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab));
document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel));
await loadApiTokens();
});
}
// Shows a freshly issued token exactly once. No "x" close button -- Close only.
function showIssuedApiToken(result) {
const dialog = featureDialog("api-token-created-dialog");
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Copy your token now</h2></div><button type="button" class="icon-button" id="copy-api-token" aria-label="Copy token" title="Copy token">${featureCopyIcon}</button></div><p class="muted">This is the only time Site Gateway will show this token. Store it somewhere safe — only its hash is kept.</p><code class="api-token-secret">${extendedEscape(result.token)}</code><div class="dialog-actions"><button value="cancel" class="button primary">Close</button></div></form>`;
dialog.querySelector("#copy-api-token").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(result.token); toast("API token copied."); }
catch { toast("Your browser blocked clipboard access.", "error"); }
});
dialog.showModal();
}
document.addEventListener("click", async event => {
if (!event.target.closest("#create-api-token")) return;
const dialog = featureDialog("create-api-token-dialog");
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Create an API token</h2></div></div><p class="muted">Re-enter your administrator credentials to confirm. The token inherits your role.</p><label>Token name<input name="name" maxlength="60" required placeholder="Home Assistant integration"></label><label>Scope<select name="scope"><option value="full">Full access — read and change configuration</option><option value="read-only">Read-only — GET requests only</option></select></label><label>Expires after <span class="optional">Optional</span><input name="expiresInDays" type="number" min="1" max="3650" placeholder="Leave empty for no expiry"><small>Number of days. Leave empty for a token that never expires.</small></label><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><p class="error" id="api-token-error"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create token</button></div></form>';
dialog.showModal();
const outcome = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true }));
if (outcome !== "confirm") return;
const form = new FormData(dialog.querySelector("form"));
const expiresInDays = Number(form.get("expiresInDays"));
try {
const result = await api("/api/tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.get("name"), scope: form.get("scope"), expiresInDays: Number.isFinite(expiresInDays) && expiresInDays > 0 ? expiresInDays : null, username: form.get("username"), password: form.get("password") }) });
await loadApiTokens();
showIssuedApiToken(result);
} catch (error) { toast(error.message, "error"); }
});
document.addEventListener("click", async event => {
const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
const row = button.closest("[data-token-id]"); if (!row) return;
try { await api(`/api/tokens/${encodeURIComponent(row.dataset.tokenId)}`, { method: "DELETE" }); await loadApiTokens(); toast("API token revoked."); }
catch (error) { toast(error.message, "error"); }
});
// --- Administration > Backup & restore: history timeline --------------------------------------
// Deliberately never renders a raw .sgbackup filename: every entry is described by what it
// was and when it happened. Filenames stay in the data for the restore/download actions above.
function backupHistoryLabel(event) {
const kind = event.backupType === "complete" ? "Complete backup" : event.backupType === "configuration" ? "Configuration backup" : event.backupType === "safety" ? "Safety backup (pre-restore)" : "Backup";
const when = formatTime(event.createdAt);
const lower = `${kind.charAt(0).toLowerCase()}${kind.slice(1)}`;
if (event.type === "restored") return `Restored from ${lower}${when}`;
if (event.type === "deleted") return `Deleted ${lower}${when}`;
if (event.type === "imported") return `Imported ${lower}${when}`;
return `${kind}${when}`;
}
async function renderBackupHistory() {
const panel = document.querySelector('[data-admin-panel="backups"]'); if (!panel || state.user?.role !== "administrator") return;
let section = panel.querySelector(".backup-history-section");
if (!section) {
section = document.createElement("div");
section.className = "dashboard-panel backup-history-section";
section.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">History</p><h2>Backup history</h2><p class="muted">Every backup, restore, import, and deletion — including failed attempts — recorded independently of what is currently stored on disk.</p></div></div><div id="backup-history-list" class="backup-history-list"><p class="quiet-state">Loading backup history…</p></div>';
panel.append(section);
}
const list = section.querySelector("#backup-history-list");
if (panel.classList.contains("hidden")) return;
try {
const events = await api("/api/backups/history");
list.innerHTML = events.length ? events.map(item => {
const failed = item.status === "failed";
const detail = failed ? `Failed — ${item.errorMessage || "no further detail recorded"}` : [item.sizeBytes ? formatBytes(item.sizeBytes) : "", item.safetyBackupFilename ? "A safety backup was taken first" : ""].filter(Boolean).join(" · ") || "Completed";
return `<div class="activity-tile"><span class="activity-mark ${failed ? "bad" : ""}">${failed ? "!" : "✓"}</span><span class="backup-history-copy"><strong>${extendedEscape(backupHistoryLabel(item))}</strong><small class="${failed ? "failed" : ""}" title="${extendedEscape(formatTime(item.createdAt))}">${extendedEscape(detail)}</small></span></div>`;
}).join("") : '<p class="quiet-state">No backup activity recorded yet.</p>';
} catch (error) { list.innerHTML = `<p class="quiet-state">${extendedEscape(error.message)}</p>`; }
}
// --- Docker container picker ------------------------------------------------------------------
// The Administration toggle is disabled whenever the socket is not mounted, regardless of the
// saved value, so the integration can never be switched on without its prerequisite.
function renderDockerPanel() {
if (state.user?.role !== "administrator") return;
const panel = document.querySelector('[data-admin-panel="defaults"]'); if (!panel) return;
const socketMounted = state.config?.docker?.socketMounted === true;
const enabled = socketMounted && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
let section = panel.querySelector(".docker-integration-section");
if (!section) {
section = document.createElement("div");
section.className = "dashboard-panel docker-integration-section";
section.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">Integrations</p><h2>Docker container selection</h2></div></div><p class="muted" id="docker-integration-help"></p><label class="check-control"><input id="docker-integration-toggle" type="checkbox"><span>Let Proxy and Streaming hosts pick a running container as their target</span></label>';
panel.append(section);
section.querySelector("#docker-integration-toggle").addEventListener("change", async event => {
const checkbox = event.currentTarget;
checkbox.disabled = true;
try { state.settings = await api("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dockerIntegration: { enabled: checkbox.checked } }) }); toast(checkbox.checked ? "Container selection enabled." : "Container selection disabled."); }
catch (error) { checkbox.checked = !checkbox.checked; toast(error.message, "error"); }
finally { checkbox.disabled = false; renderDockerPanel(); decorateContainerPickers(); }
});
}
const toggle = section.querySelector("#docker-integration-toggle");
toggle.checked = enabled;
toggle.disabled = !socketMounted;
section.querySelector("#docker-integration-help").textContent = socketMounted
? "Site Gateway reads the Docker socket read-only to list running containers, and only offers containers that share a Docker network with it."
: "Docker socket not detected — mount /var/run/docker.sock into this container to enable container selection.";
section.querySelector(".check-control").classList.toggle("is-disabled", !socketMounted);
}
// Adds the "Pick from running containers" button beside every target field, and keeps its
// visibility in step with the integration's current state.
function decorateContainerPickers() {
const available = state.config?.docker?.socketMounted === true && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
for (const selector of ["#proxy-form [name=target]", "#settings-form [name=target]", "#stream-form [name=target]"]) {
const input = document.querySelector(selector); if (!input) continue;
let wrap = input.closest(".target-with-picker");
if (!wrap) {
wrap = document.createElement("span");
wrap.className = "target-with-picker";
input.replaceWith(wrap);
wrap.append(input);
const button = document.createElement("button");
button.type = "button";
button.className = "button secondary container-picker-trigger";
button.textContent = "Pick container";
wrap.append(button);
}
wrap.querySelector(".container-picker-trigger").classList.toggle("hidden", !available);
}
}
document.addEventListener("click", async event => {
const trigger = event.target.closest(".container-picker-trigger"); if (!trigger) return;
event.preventDefault();
const input = trigger.closest(".target-with-picker")?.querySelector("input"); if (!input) return;
const dialog = featureDialog("container-picker-dialog");
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Running containers</h2></div></div><p class="muted">Loading containers…</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button></div></form>';
dialog.showModal();
let containers = [];
try { containers = (await api("/api/docker/containers")).containers || []; }
catch (error) {
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Containers unavailable</h2></div></div><p class="muted">${extendedEscape(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
return;
}
const choices = containers.map(container => {
const ports = container.ports?.length ? container.ports.join(", ") : "no published container ports";
const detail = container.reachable ? `${container.image} · ports ${ports}` : `${container.image} · ${container.reason}`;
return `<button type="button" class="container-choice ${container.reachable ? "" : "unreachable"}" ${container.reachable ? `data-container-name="${extendedEscape(container.name)}" data-container-port="${container.ports?.[0] || ""}"` : "disabled"}><strong>${extendedEscape(container.name)}</strong><small>${extendedEscape(detail)}</small></button>`;
}).join("");
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Docker</p><h2>Running containers</h2></div></div><p class="muted">Containers that do not share a Docker network with Site Gateway are dimmed — Site Gateway could not reach them by name. You can always type a target by hand instead.</p><div class="container-picker-list">${choices || '<p class="quiet-state">No running containers were reported.</p>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button></div></form>`;
dialog.querySelector(".container-picker-list")?.addEventListener("click", pickEvent => {
const choice = pickEvent.target.closest("[data-container-name]"); if (!choice) return;
const name = choice.dataset.containerName, port = choice.dataset.containerPort || "80";
// Docker's embedded DNS resolves the container name on a shared network, so use the
// name rather than an IP address, which changes whenever the container restarts.
input.value = input.type === "url" ? `http://${name}:${port}` : `${name}:${port}`;
input.dispatchEvent(new Event("input", { bubbles: true }));
dialog.close();
toast(`Target set to ${name}:${port}.`);
});
});
// --- Wire the new panels into the shared refresh entry point ----------------------------------
const baseRenderExtendedViews = window.renderExtendedViews;
window.renderExtendedViews = function () {
baseRenderExtendedViews();
renderApiTokensPanel();
renderDockerPanel();
decorateContainerPickers();
renderBackupHistory();
normalizeAdminTabOrder();
hideRestrictedControls();
};
+18 -10
View File
File diff suppressed because one or more lines are too long
+72 -7
View File
@@ -109,7 +109,7 @@ dialog::backdrop{background:rgba(var(--backdrop-rgb),.76);backdrop-filter:blur(5
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:var(--space-5)}
#account-password-form .error{margin:0;min-height:.3em}
#account-password-form .dialog-actions{margin-top:var(--space-2)}
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none}
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none;z-index:2147483647}
.toast.show{opacity:1;transform:translate(-50%,0)}
.toast.toast-error{background:var(--toast-error-bg);color:var(--toast-error-text)}
.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:var(--space-4);background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-md);padding:14px var(--space-4) 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}
@@ -158,6 +158,10 @@ header{align-items:flex-end}
.metric-card:is(button):hover{transform:translateY(-2px);border-color:var(--border-hover-alt);box-shadow:0 16px 40px rgba(var(--black-rgb),.22)}
.metric-card.accent-green{--card-accent:var(--green)}
.metric-card.accent-blue{--card-accent:var(--blue)}
.metric-card.accent-warning{--card-accent:var(--warning)}
.metric-card.accent-warning strong{color:var(--warning)}
.metric-card.accent-danger{--card-accent:var(--danger)}
.metric-card.accent-danger strong{color:var(--danger)}
.metric-card .metric-label,.metric-card>span:last-child{display:block}
.metric-card .metric-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:700}
.metric-card .metric-icon{position:absolute;top:var(--space-4);right:var(--space-4);width:32px;height:32px;display:grid;place-items:center;border-radius:var(--radius-sm);background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.9rem;font-weight:800}
@@ -183,6 +187,8 @@ header{align-items:flex-end}
.metric-chip.accent-green{--card-accent:var(--green)}
.metric-chip.accent-green strong{color:var(--green)}
.metric-chip.accent-blue{--card-accent:var(--blue)}
.metric-chip.accent-danger{--card-accent:var(--danger)}
.metric-chip.accent-danger strong{color:var(--danger)}
.dashboard-columns{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}
.dashboard-columns.lower.attention-clear{grid-template-columns:1fr}
.dashboard-jobs-slot:not(:empty){margin-top:18px}
@@ -212,7 +218,7 @@ header{align-items:flex-end}
.live-dot.checking{background:var(--warning);animation-duration:.9s}
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
.system-panel{position:relative;overflow:hidden}
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--blue);opacity:.85}
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85}
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
.system-grid dt{color:var(--muted);font-size:.72rem}
@@ -228,6 +234,8 @@ header{align-items:flex-end}
.attention-tile{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:13px 14px;border:1px solid var(--line);border-left:3px solid var(--danger);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
.issue-link.attention-tile{cursor:pointer;transition:.2s}
.issue-link.attention-tile:hover{background:rgba(var(--danger-rgb),.08);border-color:var(--border-hover-alt)}
.drift-tile .attention-copy{flex:1}
.drift-tile .button{padding:var(--space-2) 12px;font-size:.72rem;flex-shrink:0}
.attention-copy,.activity-copy{display:grid;gap:3px;min-width:0}
.attention-copy strong,.activity-copy strong{font-size:var(--font-size-md)}
.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}
@@ -270,6 +278,7 @@ header{align-items:flex-end}
.performance-sparkline-labels .axis-label{position:absolute;transform:translateY(-50%);color:var(--muted);font-size:var(--font-size-xs);white-space:nowrap}
.performance-sparkline-labels .time-label{position:absolute;bottom:0;color:var(--muted);font-size:var(--font-size-xs);white-space:nowrap}
.performance-sparkline-labels .time-label.time-label-end{transform:translateX(-100%)}
.performance-sparkline-labels .time-label.time-label-mid{transform:translateX(-50%)}
.row-highlight{background:rgba(var(--green-rgb),.08)}
.user-head-actions{display:flex;align-items:center;gap:10px}
[data-admin-panel="backups"]>.dashboard-panel{margin-top:var(--space-5)}
@@ -864,17 +873,15 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.performance-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md);table-layout:fixed}
.performance-table th,.performance-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.performance-table thead th{border-top:0;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:normal}
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:34%}
.performance-table th:nth-child(2),.performance-table td:nth-child(2){width:20%;text-align:center}
.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:23%;text-align:center}
.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:23%;text-align:center}
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:22%}
.performance-table th:nth-child(n+2),.performance-table td:nth-child(n+2){width:11.1%;text-align:center}
.performance-table .count-divider{color:var(--muted);margin:0 2px}
.performance-table .count-link-button{border:0;background:none;padding:0;font:inherit;color:var(--danger);cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px}
.performance-table .count-link-button:hover{text-decoration-style:solid}
.error-breakdown-list{margin-top:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28);padding:0 var(--space-4)}
.error-breakdown-row{display:flex;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) 0;border-top:1px solid var(--line);font-size:var(--font-size-md)}
.error-breakdown-row:first-child{border-top:0}
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:30%}}
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2),.performance-table th:nth-child(5),.performance-table td:nth-child(5),.performance-table th:nth-child(6),.performance-table td:nth-child(6),.performance-table th:nth-child(7),.performance-table td:nth-child(7){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:15%}.performance-table th:nth-child(8),.performance-table td:nth-child(8){width:15%}}
/* MFA setup: QR code & recovery codes */
.mfa-qr{display:flex;justify-content:center;padding:var(--space-4);background:var(--white);border-radius:var(--radius-md);margin:var(--space-4) 0}
@@ -882,3 +889,61 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.mfa-recovery-codes{background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:var(--space-4);font-size:14px;line-height:1.8;letter-spacing:.02em;white-space:pre-wrap;user-select:all}
#account-view>.dashboard-panel+.dashboard-panel{margin-top:18px}
/* ---------------------------------------------------------------------------
v0.15.0 additions: API access tokens, backup history, Docker container
picker, "View Caddy config" popout, and the Performance screen overhaul.
Every colour, radius, and spacing value below resolves to a :root token.
--------------------------------------------------------------------------- */
/* Performance: chart axis unit, hover tooltip, and the slowest-requests panel */
.chart-axis-unit{margin:0 0 var(--space-2);color:var(--muted);font-size:var(--font-size-xs);font-weight:800;letter-spacing:.1em;text-transform:uppercase}
.performance-tooltip{position:absolute;z-index:4;pointer-events:none;transform:translate(-50%,-118%);padding:var(--space-2) var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--panel2);color:var(--text);font-size:var(--font-size-xs);line-height:1.5;white-space:nowrap;box-shadow:0 10px 30px rgba(var(--black-rgb),.35)}
.performance-tooltip strong{display:block;font-size:var(--font-size-sm)}
.performance-tooltip .tooltip-errors{color:var(--warning)}
.performance-tooltip .tooltip-errors.none{color:var(--muted)}
.slowest-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
.slowest-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
.slowest-copy strong{font-size:var(--font-size-md);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slowest-copy small{color:var(--muted);font-size:var(--font-size-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slowest-duration{flex:0 0 auto;font-weight:800;color:var(--warning)}
.performance-table .count-link-button.neutral{color:var(--blue)}
.top-paths-list{margin-top:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28);padding:0 var(--space-4)}
.top-paths-row{display:flex;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) 0;border-top:1px solid var(--line);font-size:var(--font-size-md)}
.top-paths-row:first-child{border-top:0}
.top-paths-row span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.error-breakdown-group{margin:var(--space-4) 0 0;color:var(--muted);font-size:var(--font-size-xs);font-weight:800;letter-spacing:.08em;text-transform:uppercase}
.error-breakdown-row.client-error span:first-child{color:var(--warning)}
.error-breakdown-row.server-error span:first-child{color:var(--danger)}
/* "View Caddy config" popout */
.caddy-config-pre{margin:var(--space-4) 0 0;max-height:52vh;overflow:auto;padding:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.65;white-space:pre}
.dialog-heading .icon-button{flex:0 0 auto;align-self:flex-start}
.copy-icon{width:16px;height:16px;display:block}
/* Docker container picker */
.target-with-picker{display:flex;gap:var(--space-2);align-items:center}
.target-with-picker input{flex:1;min-width:0}
.container-picker-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:46vh;overflow:auto}
.container-choice{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:var(--space-3) 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.22);color:var(--text);text-align:left;cursor:pointer}
.container-choice:hover{border-color:var(--border-hover)}
.container-choice small{color:var(--muted);font-size:var(--font-size-xs)}
.container-choice.unreachable{opacity:.5;cursor:not-allowed}
.container-choice.unreachable:hover{border-color:var(--line)}
/* API access tokens */
.api-token-row{grid-template-columns:auto minmax(150px,1.3fr) minmax(110px,.9fr) minmax(130px,1fr) minmax(150px,1fr)}
.api-token-row.revoked{opacity:.6}
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
.api-token-chip{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm)}
/* Backup history timeline */
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
.backup-history-copy strong{font-size:var(--font-size-md)}
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
.backup-history-copy small.failed{color:var(--danger)}
.check-control.is-disabled{opacity:.55}
.check-control.is-disabled span{color:var(--muted)}
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
+345 -35
View File
@@ -64,6 +64,8 @@ const recentActivity = [];
const upstreamHealth = new Map();
const certificateStatusCache = new Map();
const loginAttempts = new Map();
const rateLimitBuckets = new Map();
let dockerSocketMounted = false;
let currentAuditActor = null;
const probeFailures = { gateway: 0, http: 0, https: 0 };
let iconCatalog = null;
@@ -146,6 +148,99 @@ function sessionUser(req) {
return user;
}
// Small fixed-window in-memory rate limiter shared by the sign-in routes and the
// bearer-token authentication path. Map of key -> { count, windowStart }; no dependency.
function rateLimitExceeded(key, limit = 10, windowMs = 60000) {
const nowMs = Date.now();
const bucket = rateLimitBuckets.get(key);
if (!bucket || nowMs - bucket.windowStart >= windowMs) { rateLimitBuckets.set(key, { count: 1, windowStart: nowMs }); return false; }
bucket.count += 1;
if (rateLimitBuckets.size > 5000) for (const [entryKey, entry] of rateLimitBuckets) if (nowMs - entry.windowStart >= windowMs) rateLimitBuckets.delete(entryKey);
return bucket.count > limit;
}
function requestKey(req) { return req.ip || req.socket?.remoteAddress || "unknown"; }
// Rotating a user's sessionVersion invalidates every session cookie they hold AND every API
// token they issued, because tokens store the version that was current when they were created.
// Callers that rotate the CURRENT user's version re-issue their cookie so they stay signed in.
function rotateSessionVersion(user) {
user.sessionVersion = crypto.randomBytes(16).toString("hex");
user.updatedAt = new Date().toISOString();
}
// --- REST API tokens ---------------------------------------------------------------------
// Only the SHA-256 hash of a token is ever stored; the raw value is shown once at creation.
function hashApiToken(rawToken) { return crypto.createHash("sha256").update(String(rawToken)).digest("hex"); }
// Resolves an `Authorization: Bearer <token>` header to its owning user. Tokens carry the
// owner's sessionVersion from the moment they were issued, so a password reset, an MFA
// change, or a deactivation invalidates every token that user issued -- exactly like a
// session cookie.
function bearerTokenUser(req) {
const header = String(req.headers.authorization || "");
if (!/^Bearer\s+/i.test(header)) return null;
const raw = header.replace(/^Bearer\s+/i, "").trim();
if (!raw) return null;
let record = null;
try { record = storage.findApiTokenByHash(hashApiToken(raw)); } catch { return null; }
if (!record || record.revokedAt) return null;
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now()) return null;
const owner = users.find(item => item.id === record.ownerUserId);
if (!owner || owner.status !== "active") return null;
if (record.sessionVersion && record.sessionVersion !== owner.sessionVersion) return null;
return { user: owner, token: record };
}
// --- Docker integration (optional, off by default) ---------------------------------------
// Talks to the Docker Engine API over the mounted UNIX socket using Node's built-in http
// module -- no client library, and nothing happens at all unless the socket is mounted AND
// an administrator has explicitly turned the integration on.
const DOCKER_SOCKET_PATH = "/var/run/docker.sock";
function detectDockerSocket() {
try { if (!fs.existsSync(DOCKER_SOCKET_PATH)) return false; fs.accessSync(DOCKER_SOCKET_PATH, fs.constants.R_OK); return true; }
catch { return false; }
}
function dockerRequest(requestPath) {
return new Promise((resolve, reject) => {
const request = http.request({ socketPath: DOCKER_SOCKET_PATH, path: requestPath, method: "GET", timeout: 5000 }, response => {
let data = "";
response.on("data", chunk => { data += chunk; });
response.on("end", () => {
if (response.statusCode >= 400) return reject(Object.assign(new Error(`Docker replied with status ${response.statusCode}.`), { status: 502 }));
try { resolve(JSON.parse(data || "null")); } catch (error) { reject(new Error(`Could not read Docker's response: ${error.message}`)); }
});
});
request.on("error", error => reject(Object.assign(new Error(`Could not reach the Docker socket: ${error.message}`), { status: 502 })));
request.on("timeout", () => request.destroy(new Error("The Docker socket did not respond in time.")));
request.end();
});
}
// Lists running containers, annotated with whether they share a Docker network with Site
// Gateway's own container. Unreachable containers are returned too (flagged, with a reason)
// so the picker can dim them rather than hide them.
async function dockerContainerOptions() {
const ownId = String(process.env.HOSTNAME || "").trim();
const own = ownId ? await dockerRequest(`/containers/${encodeURIComponent(ownId)}/json`).catch(() => null) : null;
const ownNetworks = new Set(Object.keys(own?.NetworkSettings?.Networks || {}));
const running = await dockerRequest(`/containers/json?filters=${encodeURIComponent(JSON.stringify({ status: ["running"] }))}`) || [];
return running.filter(container => !own?.Id || container.Id !== own.Id).map(container => {
const networks = Object.keys(container.NetworkSettings?.Networks || {});
const shared = networks.filter(name => ownNetworks.has(name));
const reachable = ownNetworks.size > 0 && shared.length > 0;
return {
id: String(container.Id || "").slice(0, 12),
name: (container.Names || []).map(value => String(value).replace(/^\//, "")).filter(Boolean)[0] || String(container.Id || "").slice(0, 12),
image: container.Image || "",
state: container.State || "running",
networks,
sharedNetworks: shared,
ports: [...new Set((container.Ports || []).map(port => Number(port.PrivatePort)).filter(Boolean))].sort((a, b) => a - b),
reachable,
reason: reachable ? null : ownNetworks.size ? "Not on a Docker network shared with Site Gateway." : "Site Gateway could not identify its own container, so shared networks are unknown."
};
});
}
const saveSites = async () => storage.saveCollection("sites", sites);
const saveProxies = async () => storage.saveCollection("proxies", proxies);
const saveUsers = async () => storage.saveCollection("users", users);
@@ -414,23 +509,18 @@ async function writeDefaultSitePage() {
// renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state
// (sites, proxies, redirects, streams, access lists, default site settings).
function renderCaddyfile() {
const email = String(process.env.ACME_EMAIL || "").trim();
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
if (email) lines.push(` email ${email}`);
const logging = [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
lines.push("}", "", ":80 {", ...logging);
const defaultSite = settings.defaultSite || {};
if (defaultSite.mode === "abort") lines.push(" abort");
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
lines.push("}");
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) {
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
lines.push("}");
function caddyLoggingDirectives() {
return [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
}
for (const proxy of proxies.filter(item => item.enabled && item.domain)) {
lines.push("", `${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy));
// One builder per route kind, each returning the exact Caddyfile lines that route
// contributes. renderCaddyfile()'s loops and the "View Caddy config" endpoint both call
// these, so the popout can never drift from the configuration Caddy actually runs.
function renderHostedSiteBlock(site, logging = caddyLoggingDirectives()) {
return [`${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server", "}"];
}
function renderProxyBlock(proxy, logging = caddyLoggingDirectives()) {
const lines = [`${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy)];
for (const location of proxy.locations || []) {
lines.push(` ${location.stripPrefix ? "handle_path" : "handle"} ${location.path} {`, ...proxyBlock(location.target, location, " "), " }");
}
@@ -438,15 +528,101 @@ function renderCaddyfile() {
else lines.push(...proxyBlock(proxy.target, proxy));
if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`));
lines.push("}");
return lines;
}
for (const redirect of redirects.filter(item => item.enabled && item.domain)) {
function renderRedirectBlock(redirect, logging = caddyLoggingDirectives()) {
const target = `${redirect.target}${redirect.preservePath ? "{uri}" : ""}`;
lines.push("", `${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}");
return [`${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}"];
}
function renderCaddyfile() {
const email = String(process.env.ACME_EMAIL || "").trim();
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
if (email) lines.push(` email ${email}`);
const logging = caddyLoggingDirectives();
lines.push("}", "", ":80 {", ...logging);
const defaultSite = settings.defaultSite || {};
if (defaultSite.mode === "abort") lines.push(" abort");
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
lines.push("}");
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) lines.push("", ...renderHostedSiteBlock(site, logging));
for (const proxy of proxies.filter(item => item.enabled && item.domain)) lines.push("", ...renderProxyBlock(proxy, logging));
for (const redirect of redirects.filter(item => item.enabled && item.domain)) lines.push("", ...renderRedirectBlock(redirect, logging));
return `${lines.join("\n")}\n`;
}
// --- "View Caddy config" pretty renderer -------------------------------------------------
// Re-uses the block builders above and annotates each directive with a plain-language
// comment, so the popout explains the real configuration rather than a paraphrase of it.
const CADDY_DIRECTIVE_NOTES = [
[/^\s*log \{/, "Write this host's requests to the access log Site Gateway reads"],
[/^\s*reverse_proxy /, "Reverse proxy to the configured upstream"],
[/^\s*lb_policy /, "How requests are spread across the configured upstreams"],
[/^\s*transport http \{/, "Connection options used when talking to the upstream"],
[/^\s*tls_insecure_skip_verify/, "Accept the upstream's certificate without verifying it"],
[/^\s*tls_server_name /, "Certificate name expected on the upstream"],
[/^\s*response_header_timeout /, "How long to wait for the upstream's response headers"],
[/^\s*header_up /, "Header added to the request before it reaches the upstream"],
[/^\s*tls internal/, "Enforce HTTPS using Caddy's own internal certificate authority"],
[/^\s*tls "/, "Enforce HTTPS using the certificate and private key you uploaded"],
[/^\s*header Strict-Transport-Security/, "Tell browsers to always use HTTPS for this host"],
[/^\s*header /, "Response header added to every reply from this host"],
[/^\s*encode /, "Compress responses that benefit from it"],
[/^\s*root \* /, "Folder this site's files are served from"],
[/^\s*file_server/, "Serve the files in that folder directly"],
[/^\s*redir /, "Send visitors to the redirect destination"],
[/^\s*handle_path /, "Match this path prefix and strip it before forwarding"],
[/^\s*handle \{/, "Everything not matched above is handled here"],
[/^\s*handle /, "Match this path prefix and forward it unchanged"],
[/^\s*abort/, "Close the connection without replying"],
[/^\s*respond .* 403/, "Reject requests matching the common-exploit ruleset"],
[/^\s*forward_auth /, "Require an Access List sign-in before allowing the request"],
[/^\s*path_regexp /, "Pattern of known exploit-probe paths"],
[/^\s*@/, "Named matcher used by the directive below"]
];
// Container-specific absolute paths, flagged so the config is readable outside this container.
const CADDY_PATH_NOTES = [
[/^\s*root \* /, "container path - this folder lives inside the Site Gateway container"],
[/^\s*output file /, "container path - the access log file inside the Site Gateway container"],
[/^\s*tls "\//, "container paths - the certificate and key files stored inside the Site Gateway container"]
];
function annotateCaddyLines(lines) {
const output = [];
for (const line of lines) {
if (!line.trim()) { output.push(""); continue; }
const indent = " ".repeat(line.length - line.trimStart().length);
const note = CADDY_DIRECTIVE_NOTES.find(([pattern]) => pattern.test(line))?.[1];
const pathNote = CADDY_PATH_NOTES.find(([pattern]) => pattern.test(line))?.[1];
if (note) {
const previous = output[output.length - 1];
if (previous !== undefined && previous.trim() && !/\{$/.test(previous)) output.push("");
output.push(`${indent}# ${note}`);
}
output.push(pathNote ? `${line} # ${pathNote}` : line);
}
return output;
}
function prettyCaddyConfig(kind, item) {
const builder = kind === "sites" ? renderHostedSiteBlock : kind === "proxies" ? renderProxyBlock : renderRedirectBlock;
const heading = kind === "sites" ? "Hosted site" : kind === "proxies" ? "Proxy host" : "Redirect host";
const tlsNote = item.tls === "http" ? "# Served over plain HTTP - no certificate is requested for this host."
: item.tls === "internal" ? "# Enforce HTTPS with Caddy's internal certificate authority."
: item.tls === "custom" ? "# Enforce HTTPS with the certificate and private key you uploaded."
: "# Enforce HTTPS with an automatically-issued certificate.";
return [
`# ${heading} - ${item.name || item.domain || item.id}`,
`# Generated by Site Gateway v${appVersion}. This is the exact block this route`,
"# contributes to the deployed Caddyfile, with explanatory comments added.",
tlsNote,
"",
...annotateCaddyLines(builder(item)),
""
].join("\n");
}
// syncCaddy -- applies the generated config to the running Caddy instance and
// records success/failure (gatewayError, lastGatewayReload) for the dashboard.
async function syncCaddy() {
@@ -496,7 +672,15 @@ function caddyAdminRequest(options, body) {
request.end();
});
}
function stableStringify(value) {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function checkConfigDrift() {
const wasDrifting = configDrift.drift;
try {
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
if (!caddyfileContent) return;
@@ -508,8 +692,16 @@ async function checkConfigDrift() {
const adaptedParsed = JSON.parse(adapted.body);
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
const liveConfig = JSON.parse(live.body);
const drift = JSON.stringify(adaptedConfig) !== JSON.stringify(liveConfig);
// Compare with sorted-key serialization, not raw JSON.stringify: Caddy\u2019s freshly-adapted
// config and its live running config can serialize object keys in a different order even when
// they\u2019re semantically identical, which previously registered as a false-positive drift.
const drift = stableStringify(adaptedConfig) !== stableStringify(liveConfig);
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the saved configuration." : null };
// Log only the false->true transition (not every 10-minute check while it persists or is clear),
// so Gateway Events shows exactly when drift was (re-)detected \u2014 useful for confirming whether
// a report of drift reappearing "right after resync" actually lines up with a real check, or with
// this job\u2019s normal 10-minute cadence instead.
if (drift && !wasDrifting) recordActivity("Configuration drift detected: Caddy\u2019s live configuration no longer matches the saved configuration.", "warning");
} catch (error) {
// Caddy admin API unreachable, or transient error: don\u2019t flag drift on a check we couldn\u2019t complete.
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
@@ -788,11 +980,11 @@ async function dashboardSnapshot() {
if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." });
if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." });
if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." });
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.` });
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." });
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` });
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.`, target: "hosted" });
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention.", target: "proxies" });
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.`, target: "proxies" });
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration" });
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
const disk = await fsp.statfs(dataDir).catch(() => null);
const databaseIntegrity = storage.integrity();
return {
@@ -1012,7 +1204,7 @@ async function openBackup(filename, password = "") {
return { zip: new AdmZip(buffer), encrypted };
}
async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
async function createBackupInternal(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
const safeType = type === "complete" ? "complete" : "configuration";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `${prefix}-${stamp}.sgbackup`;
@@ -1036,6 +1228,22 @@ async function createBackup(type = "configuration", includeLogs = false, prefix
return { filename, path: destination, ...manifest, size: (await fsp.stat(destination)).size };
}
// Wraps createBackupInternal so every attempt -- successful or not -- is written to the
// backup_events history table, which is independent of what currently exists on disk.
async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
const safeType = type === "complete" ? "complete" : "configuration";
const backupType = prefix === "pre-restore" ? "safety" : safeType;
try {
const result = await createBackupInternal(type, includeLogs, prefix, password);
try { storage.recordBackupEvent({ type: "created", filename: result.filename, backupType, sizeBytes: result.size, actorUserId: currentAuditActor, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
return result;
} catch (error) {
try { storage.recordBackupEvent({ type: "created", filename: null, backupType, actorUserId: currentAuditActor, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
recordActivity(`${backupType === "safety" ? "Pre-restore safety" : safeType === "complete" ? "Complete" : "Configuration"} backup failed: ${error.message}`, "error");
throw error;
}
}
async function listBackups() {
const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup"));
return Promise.all(names.map(async filename => {
@@ -1088,7 +1296,10 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
for (const site of sites.filter(item => item.enabled)) await startSite(site);
for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } }
await syncCaddy(); recordActivity(`Backup ${filename} restored.`);
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
} catch (error) {
recordActivity(`Restore of ${filename} failed: ${error.message}`, "error");
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
if (safetyBackup) {
try { await restoreBackup(safetyBackup.filename, "", false); recordActivity(`Restore of ${filename} failed; the pre-restore state was recovered.`, "error"); }
catch (rollbackError) { error.message = `${error.message} Automatic rollback also failed: ${rollbackError.message}`; }
@@ -1119,6 +1330,9 @@ for (let attempt = 0; attempt < 10; attempt++) {
}
}
dockerSocketMounted = detectDockerSocket();
if (!dockerSocketMounted) console.log("Docker socket not detected at /var/run/docker.sock - container selection stays unavailable.");
const app = express();
const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } });
const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } });
@@ -1173,6 +1387,7 @@ function pendingMfaUser(req) {
app.post("/api/login", async (req, res, next) => {
try {
const key = req.ip || req.socket.remoteAddress || "unknown";
if (rateLimitExceeded(`login:${key}`, 10, 60000)) { recordActivity(`Security: sign-in request rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in requests. Try again in a minute." }); }
const attempt = checkLoginRateLimit(key);
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
const username = String(req.body.username || "").trim().toLowerCase();
@@ -1244,6 +1459,7 @@ app.get("/_site-gateway/login", (req, res) => {
});
app.post("/_site-gateway/login", async (req, res, next) => {
try {
if (rateLimitExceeded(`access-login:${requestKey(req)}`, 10, 60000)) return res.status(429).type("text").send("Too many sign-in requests. Try again in a minute.");
const listId = String(req.body.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false), username = String(req.body.username || "").trim(); const credential = list?.credentials?.find(item => item.username === username) || ((list && accessUserAllowed(list, username)) ? users.find(user => user.username === username && user.status === "active") : null);
const safeReturn = String(req.body.return || "/").startsWith("/") && !String(req.body.return).startsWith("//") ? String(req.body.return) : "/";
if (!credential?.password || !await passwordMatches(req.body.password || "", credential.password)) return res.redirect(303, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}&error=1`);
@@ -1254,6 +1470,19 @@ app.post("/_site-gateway/login", async (req, res, next) => {
// --- First-run admin setup ---------------------------------------------------------------------------------
app.use("/api", (req, res, next) => {
// A bearer token authenticates as its owner and inherits that owner's role for every
// role check further down. Cookie sessions remain the fallback.
if (/^Bearer\s+/i.test(String(req.headers.authorization || ""))) {
if (rateLimitExceeded(`bearer:${requestKey(req)}`, 10, 60000)) return res.status(429).json({ error: "Too many API requests. Try again in a minute." });
const match = bearerTokenUser(req);
if (!match) return res.status(401).json({ error: "That API token is not valid, has expired, or has been revoked." });
if (match.token.scope === "read-only" && req.method !== "GET") return res.status(403).json({ error: "This API token is read-only." });
if (!match.token.lastUsedAt || Date.now() - new Date(match.token.lastUsedAt).getTime() > 60000) {
try { storage.touchApiToken(match.token.id); } catch { /* Last-used tracking is best effort. */ }
}
req.user = match.user; req.apiToken = match.token;
return next();
}
const user = sessionUser(req);
if (!user) return res.status(401).json({ error: "Please sign in." });
req.user = user;
@@ -1291,8 +1520,8 @@ app.post("/api/account/password", async (req, res, next) => {
if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." });
req.user.password = await passwordRecord(newPassword);
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`);
rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password. Other sessions and API tokens were signed out.`);
issueSessionCookie(res, req.user);
res.json({ ok: true });
} catch (error) { next(error); }
@@ -1326,8 +1555,9 @@ app.post("/api/account/mfa/disable", async (req, res, next) => {
try {
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = [];
req.user.updatedAt = new Date().toISOString();
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning");
rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication. Other sessions and API tokens were signed out.`, "warning");
issueSessionCookie(res, req.user);
res.json({ ok: true });
} catch (error) { next(error); }
});
@@ -1344,7 +1574,7 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
});
// --- 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 }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) } }));
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 }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
app.post("/api/gateway/resync", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
@@ -1356,6 +1586,41 @@ app.post("/api/gateway/resync", async (req, res, next) => {
});
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." }));
// --- REST API access tokens: list / issue / revoke -----------------------------------------
app.get("/api/tokens", (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
} catch (error) { next(error); }
});
app.post("/api/tokens", async (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
if (req.apiToken) return res.status(403).json({ error: "New API tokens can only be issued from a signed-in session." });
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." });
const name = String(req.body.name || "").trim();
if (!name || name.length > 60) return res.status(400).json({ error: "Enter a name of 60 characters or fewer for this token." });
const scope = req.body.scope === "read-only" ? "read-only" : "full";
const requestedDays = Number(req.body.expiresInDays);
const expiresAt = Number.isFinite(requestedDays) && requestedDays > 0 ? new Date(Date.now() + Math.min(requestedDays, 3650) * 86400000).toISOString() : null;
if (!req.user.sessionVersion) { req.user.sessionVersion = crypto.randomBytes(16).toString("hex"); req.user.updatedAt = new Date().toISOString(); await saveUsers(); }
const rawToken = `sgt_${crypto.randomBytes(32).toString("base64url")}`;
const record = storage.createApiToken({ id: crypto.randomUUID(), name, tokenHash: hashApiToken(rawToken), prefix: rawToken.slice(0, 8), ownerUserId: req.user.id, scope, sessionVersion: req.user.sessionVersion, expiresAt });
recordActivity(`Security: API token \u201c${name}\u201d issued for \u201c${req.user.username}\u201d.`);
res.status(201).json({ ...record, ownerUsername: req.user.username, revoked: false, token: rawToken });
} catch (error) { next(error); }
});
app.delete("/api/tokens/:id", (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
if (!storage.revokeApiToken(req.params.id)) return res.status(409).json({ error: "That API token has already been revoked." });
recordActivity(`Security: API token \u201c${existing.name}\u201d revoked.`);
res.status(204).end();
} catch (error) { next(error); }
});
app.post("/api/users", async (req, res, next) => {
try {
const username = String(req.body.username || "").trim().toLowerCase();
@@ -1390,12 +1655,17 @@ app.patch("/api/users/:id", async (req, res, next) => {
if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." });
user.displayName = displayName;
}
let invalidateSessions = nextStatus !== "active";
if (req.body.password !== undefined) {
const password = String(req.body.password);
if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." });
user.password = await passwordRecord(password);
invalidateSessions = true;
}
user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`);
// A password reset or a deactivation must also drop this user's API tokens.
if (invalidateSessions) rotateSessionVersion(user);
user.updatedAt = new Date().toISOString(); await saveUsers();
if (invalidateSessions && user.id === req.user.id) issueSessionCookie(res, user); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`);
res.json(publicUser(user));
} catch (error) { next(error); }
});
@@ -1419,9 +1689,10 @@ app.post("/api/users/:id/mfa/disable", async (req, res, next) => {
if (!user) return res.status(404).json({ error: "User not found." });
if (!user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn\u2019t enabled for this user." });
user.mfaEnabled = false; user.mfaSecret = null; user.mfaPendingSecret = null; user.mfaRecoveryCodes = [];
user.updatedAt = new Date().toISOString();
rotateSessionVersion(user);
await saveUsers();
recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”.`, "warning");
if (user.id === req.user.id) issueSessionCookie(res, user);
recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”. That users sessions and API tokens were signed out.`, "warning");
res.json({ ok: true });
} catch (error) { next(error); }
});
@@ -1467,6 +1738,16 @@ app.get("/api/support-report", async (req, res, next) => {
});
// --- Upstream health, Logs, and Performance (request throughput/trend) endpoints --------------------------------
// --- Docker: list running containers for the target picker -----------------------------------
app.get("/api/docker/containers", async (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
if (!dockerSocketMounted) return res.status(409).json({ error: "Docker socket not detected \u2014 mount /var/run/docker.sock into this container to enable container selection." });
if (settings.dockerIntegration?.enabled !== true) return res.status(409).json({ error: "Docker integration is turned off. Enable it in Administration \u2192 Gateway defaults." });
res.json({ checkedAt: new Date().toISOString(), containers: await dockerContainerOptions() });
} catch (error) { next(error); }
});
app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy)));
app.post("/api/upstreams/check", async (req, res, next) => {
try { res.json(await checkAllProxies()); }
@@ -1486,17 +1767,34 @@ app.get("/api/performance", (req, res, next) => {
const bucketMinutes = hours > 24 ? 60 : 15;
const breakdownByHost = new Map();
for (const row of storage.performanceErrorBreakdown()) { if (!breakdownByHost.has(row.host)) breakdownByHost.set(row.host, []); breakdownByHost.get(row.host).push({ status: row.status, count: row.count }); }
const percentiles = storage.performancePercentiles(0.95);
const topPaths = storage.performanceTopPaths(10);
res.json({
checkedAt: new Date().toISOString(),
liveRequests: storage.performanceLiveCount(60),
routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, errorBreakdown: (breakdownByHost.get(row.host) || []).slice(0, 3) })),
routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, hourBytes: row.hourBytes || 0, hourVisitors: row.hourVisitors || 0, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, dayBytes: row.dayBytes || 0, dayVisitors: row.dayVisitors || 0, hourP95Ms: percentiles[row.host]?.hourP95 ?? null, dayP95Ms: percentiles[row.host]?.dayP95 ?? null, topPaths: topPaths[row.host] || [], errorBreakdown: breakdownByHost.get(row.host) || [] })),
trend: storage.performanceTrend(host, hours, bucketMinutes),
slowest: storage.performanceSlowest(host, hours, 20),
hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort()
});
} catch (error) { next(error); }
});
// --- Icon search and per-entity icon upload/URL/removal -----------------------------------------------------------
// --- "View Caddy config": the annotated Caddyfile block for one route -------------------------
// Streaming hosts are deliberately excluded: they are raw TCP/UDP forwards handled by
// startStream() through net/dgram, and never appear in the Caddyfile at all.
app.get("/api/:kind/:id/caddy-config", (req, res, next) => {
try {
const kind = String(req.params.kind);
if (!["sites", "proxies", "redirects"].includes(kind)) return res.status(400).json({ error: "A Caddy configuration view is only available for hosted sites, proxy hosts, and redirect hosts. Streaming hosts forward raw TCP/UDP traffic and are not routed through Caddy." });
const collection = kind === "sites" ? sites : kind === "proxies" ? proxies : redirects;
const item = collection.find(value => value.id === req.params.id);
if (!item) return res.status(404).json({ error: "That route no longer exists." });
res.json({ kind, id: item.id, name: item.name || item.domain || item.id, config: prettyCaddyConfig(kind, item) });
} catch (error) { next(error); }
});
app.get("/api/icons/search", async (req, res, next) => {
try {
const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80);
@@ -1929,6 +2227,7 @@ app.patch("/api/settings", async (req, res, next) => {
const value = req.body.defaultSite; const mode = ["welcome","themed404","abort","redirect","custom"].includes(value.mode) ? value.mode : "themed404";
settings.defaultSite = { mode, redirectUrl: String(value.redirectUrl || "").trim(), redirectCode: [301,302,307,308].includes(Number(value.redirectCode)) ? Number(value.redirectCode) : 302, preservePath: value.preservePath !== false, title: String(value.title || "").slice(0, 100), message: String(value.message || "").slice(0, 500), customHtml: String(value.customHtml || "").slice(0, 250000) };
}
if (req.body.dockerIntegration) settings.dockerIntegration = { enabled: req.body.dockerIntegration.enabled === true && dockerSocketMounted };
if (req.body.backups) settings.backups = { ...settings.backups, ...req.body.backups, hour: Math.min(Math.max(Number(req.body.backups.hour) || 0, 0), 23), retention: Math.min(Math.max(Number(req.body.backups.retention) || 7, 1), 100) };
if (req.body.certificateHealth) {
const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120);
@@ -1952,6 +2251,7 @@ app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.
// --- 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.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
app.get("/api/backups/history", (req, res, next) => { try { res.json(storage.listBackupEvents(500)); } catch (error) { next(error); } });
app.post("/api/backups", async (req, res, next) => {
try { const backup = await createBackup(req.body.type, Boolean(req.body.includeLogs), "site-gateway-backup", String(req.body.password || "")); res.status(201).json(backup); } catch (error) { next(error); }
});
@@ -1961,7 +2261,9 @@ app.post("/api/backups/import", upload.single("backup"), async (req, res, next)
const { zip } = await openBackup(req.file.path, String(req.body.password || "")); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null");
if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 });
const filename = `imported-${new Date().toISOString().replace(/[:.]/g, "-")}.sgbackup`; await fsp.rename(req.file.path, path.join(backupsDir, filename));
recordActivity(`Backup imported from this computer.`); res.status(201).json({ filename, manifest });
recordActivity(`Backup imported from this computer.`);
try { storage.recordBackupEvent({ type: "imported", filename, backupType: manifest.type || "unknown", sizeBytes: (await fsp.stat(path.join(backupsDir, filename))).size, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
res.status(201).json({ filename, manifest });
} catch (error) { if (req.file) await fsp.rm(req.file.path, { force: true }); next(error); }
});
app.get("/api/backups/:filename/download", async (req, res, next) => {
@@ -1971,7 +2273,15 @@ app.post("/api/backups/:filename/restore", async (req, res, next) => {
try { res.json({ ok: true, manifest: await restoreBackup(path.basename(req.params.filename), String(req.body.password || "")) }); } catch (error) { next(error); }
});
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." });
const existing = (await listBackups()).find(item => item.filename === filename);
await fsp.rm(path.join(backupsDir, filename));
recordActivity(`Backup ${filename} deleted.`);
try { storage.recordBackupEvent({ type: "deleted", filename, backupType: existing?.type || (filename.startsWith("pre-restore") ? "safety" : "unknown"), sizeBytes: existing?.size ?? null, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
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, ""); }
+48 -6
View File
@@ -68,6 +68,11 @@ export async function openStorage(dataDir, backupsDir) {
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
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 INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
CREATE TABLE IF NOT EXISTS api_tokens (id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL, prefix TEXT NOT NULL, owner_user_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'full', session_version TEXT, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
CREATE INDEX IF NOT EXISTS api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS api_tokens_instance ON api_tokens(instance_id);
CREATE TABLE IF NOT EXISTS backup_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, type TEXT NOT NULL, filename TEXT, backup_type TEXT, size_bytes INTEGER, actor_user_id TEXT, created_at TEXT NOT NULL, safety_backup_filename TEXT, status TEXT NOT NULL DEFAULT 'success', error_message TEXT);
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
`);
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
const timestamp = now();
@@ -115,12 +120,16 @@ export async function openStorage(dataDir, backupsDir) {
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
SUM(CASE WHEN at>=? THEN COALESCE(size,0) ELSE 0 END) AS hourBytes,
COUNT(DISTINCT CASE WHEN at>=? THEN remote_ip END) AS hourVisitors,
COUNT(*) AS dayRequests,
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
AVG(duration_ms) AS dayAvgMs
AVG(duration_ms) AS dayAvgMs,
SUM(COALESCE(size,0)) AS dayBytes,
COUNT(DISTINCT remote_ip) AS dayVisitors
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
GROUP BY host ORDER BY dayRequests DESC
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
`).all(hourCutoff, hourCutoff, hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
}
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
@@ -134,17 +143,50 @@ export async function openStorage(dataDir, backupsDir) {
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
const cutoff = new Date(Date.now() - windowMs).toISOString();
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const rows = db.prepare(`SELECT at,status FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const buckets = new Map();
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; const entry = buckets.get(bucketStart) || { count: 0, errors: 0 }; entry.count += 1; if (Number(row.status) >= 400) entry.errors += 1; buckets.set(bucketStart, entry); }
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
const points = [];
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) { const entry = buckets.get(bucket); points.push({ at: new Date(bucket).toISOString(), count: entry?.count || 0, errors: entry?.errors || 0 }); }
return points;
}
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
// 95th-percentile latency per host. SQLite has no percentile aggregate, so the
// durations come back pre-sorted per host and the index is picked in JavaScript.
function performancePercentiles(percentile = 0.95, instanceId = LOCAL_INSTANCE_ID) {
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
const rows = db.prepare("SELECT host,at,duration_ms AS durationMs FROM access_events WHERE instance_id=? AND at>=? AND duration_ms IS NOT NULL AND host IS NOT NULL AND host!='' ORDER BY host, duration_ms").all(instanceId, dayCutoff);
const pick = values => { if (!values.length) return null; const index = Math.min(values.length - 1, Math.max(0, Math.ceil(percentile * values.length) - 1)); return Math.round(values[index]); };
const byHost = new Map();
for (const row of rows) { if (!byHost.has(row.host)) byHost.set(row.host, { day: [], hour: [] }); const entry = byHost.get(row.host); entry.day.push(row.durationMs); if (row.at >= hourCutoff) entry.hour.push(row.durationMs); }
return Object.fromEntries([...byHost].map(([host, entry]) => [host, { hourP95: pick(entry.hour), dayP95: pick(entry.day) }]));
}
function performanceTopPaths(limit = 10, instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
const cap = Math.max(1, Math.min(Number(limit) || 10, 50));
const rows = db.prepare("SELECT host,uri,COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!='' GROUP BY host,uri ORDER BY host, count DESC").all(instanceId, dayCutoff);
const byHost = new Map();
for (const row of rows) { const list = byHost.get(row.host) || []; if (list.length < cap) list.push({ uri: row.uri || "/", count: row.count }); byHost.set(row.host, list); }
return Object.fromEntries(byHost);
}
function performanceSlowest(host = "", hours = 6, limit = 20, instanceId = LOCAL_INSTANCE_ID) {
const cutoff = new Date(Date.now() - Math.max(1, Number(hours) || 6) * 3600000).toISOString();
return db.prepare("SELECT host,uri,method,status,duration_ms AS durationMs,at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?) AND duration_ms IS NOT NULL ORDER BY duration_ms DESC LIMIT ?").all(instanceId, cutoff, host, host, Math.max(1, Math.min(Number(limit) || 20, 50)));
}
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
// every authenticated API request looks a token up by its SHA-256 hash.
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
// --- Backup history. Independent of what is on disk, so deleted backups and failed
// attempts stay visible in the timeline.
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
function listBackupEvents(limit = 500, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,type,filename,backup_type AS backupType,size_bytes AS sizeBytes,actor_user_id AS actorUserId,created_at AS createdAt,safety_backup_filename AS safetyBackupFilename,status,error_message AS errorMessage FROM backup_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 500, 500))); }
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
if (isNew) {
@@ -170,5 +212,5 @@ export async function openStorage(dataDir, backupsDir) {
}
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() };
}