Add REST API tokens, backup history, Docker container picker, Caddy config popout, Performance overhaul, and dashboard tile unification (v0.15.0)

This commit is contained in:
marvin
2026-09-18 12:14:14 -04:00
parent 968066d39a
commit 59f0708382
10 changed files with 860 additions and 70 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="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="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="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.0-62E6A7">
</p> </p>
<p> <p>
<a href="#why-site-gateway">Why Site Gateway</a> · <a href="#why-site-gateway">Why Site Gateway</a> ·
+6
View File
@@ -32,5 +32,11 @@ services:
# - "25565:25565/udp" # - "25565:25565/udp"
volumes: volumes:
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data - ${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: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
+6
View File
@@ -56,3 +56,9 @@ services:
# - "25565:25565/udp" # - "25565:25565/udp"
volumes: volumes:
- ./data:/data - ./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", "name": "site-gateway",
"version": "0.14.0", "version": "0.15.0",
"private": true, "private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module", "type": "module",
+174 -13
View File
@@ -8,7 +8,7 @@
// --- Shared DOM shortcut and app state ---------------------------------------- // --- Shared DOM shortcut and app state ----------------------------------------
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, 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 // 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). // 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 renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); } function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
// 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() { function renderDashboard() {
const data = state.dashboard; if (!data) return; const data = state.dashboard; if (!data) return;
if (data.system) renderDashboardJobsSafe(data.system); if (data.system) renderDashboardJobsSafe(data.system);
@@ -161,6 +188,7 @@ function renderDashboard() {
$("#dash-stream-total").textContent = state.streams?.length || 0; $("#dash-stream-total").textContent = state.streams?.length || 0;
$("#dash-attention-total").textContent = data.attention.length; $("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues"; $("#dash-attention-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-warning", data.attention.length > 0);
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0); $("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓"; $("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
@@ -221,14 +249,14 @@ function canAdmin() { return state.user?.role === "administrator"; }
function hostedCard(site) { function hostedCard(site) {
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled"; const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`; const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
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>` : ""; 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>`; 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) { function proxyCard(proxy) {
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled"; 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 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 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"; 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>`; 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 +307,20 @@ function renderLogs() {
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline), // --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
// and the per-domain throughput table ----------------------------------------------- // 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() { function renderPerformance() {
const data = state.performance; if (!data) return; const data = state.performance; if (!data) return;
const selected = $("#performance-host").value; const selected = $("#performance-host").value;
@@ -288,9 +330,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>`; $("#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"; const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; $("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
$("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || []; const points = data.trend || [];
state.performancePoints = points;
const max = Math.max(1, ...points.map(point => point.count)); 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 plotWidth = width - left - right, plotHeight = height - top - bottom;
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth); const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
const yAt = count => top + plotHeight - (count / max) * plotHeight; const yAt = count => top + plotHeight - (count / max) * plotHeight;
@@ -299,16 +343,34 @@ function renderPerformance() {
const y = (top + plotHeight * (1 - fraction)).toFixed(1); 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" />`; return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
}).join(""); }).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 axisLabels = gridFractions.map(fraction => {
const value = Math.round(max * fraction); const value = Math.round(max * fraction);
const yPct = topInsetPct + plotHeightPct * (1 - 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>`; return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
}).join(""); }).join("");
const firstPoint = points[0], lastPoint = points[points.length - 1]; // Time axis: labels land on real clock boundaries scaled to the selected range, and the
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>` : ""; // 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}` : ""; $("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]); const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
state.performanceCoords = coords;
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => { const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`; if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1]; const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
@@ -322,16 +384,61 @@ function renderPerformance() {
const baseline = (top + plotHeight).toFixed(1); const baseline = (top + plotHeight).toFixed(1);
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : ""; 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").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>'; 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); const routes = (data.routes || []).filter(route => !selected || route.host === selected);
state.performanceErrorBreakdowns = {}; 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 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`; 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>${formatAvgMs(route.dayAvgMs)}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>'; $("#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" }); 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 --------------------------------------------------------- // --- Administration > Users view ---------------------------------------------------------
function renderUsers() { function renderUsers() {
@@ -490,7 +597,7 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
// an attention item's view ------------------------------------------------------------- // 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"; } }); $("#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"; }); $("#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) ------------------------------------------- // --- Primary navigation (sidebar view switching) -------------------------------------------
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); } function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
@@ -504,8 +611,34 @@ $("#performance-host").addEventListener("change", () => loadFeatureView().catch(
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------ // --- 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(); } function showErrorBreakdown(host, total, breakdown) {
$("#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); }); 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 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); $("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs); $("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs); $("#event-category").addEventListener("change", renderLogs);
@@ -568,6 +701,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 === "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 === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
if (action === "icon") openIconPicker(kind, card.dataset.id); if (action === "icon") openIconPicker(kind, card.dataset.id);
if (action === "caddy-config") openCaddyConfig(kind === "proxy" ? "proxies" : "sites", card.dataset.id);
}); });
// --- Redirect card actions delegated from the site grid (menu open/close, edit/ // --- Redirect card actions delegated from the site grid (menu open/close, edit/
@@ -576,8 +710,35 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => {
const card = event.target.closest(".redirect-card"); if (!card) return; const card = event.target.closest(".redirect-card"); if (!card) return;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; } if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); } const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
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 ------------------------------------ // --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; }); $("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "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. // The completed response is the only point at which this view should be replaced.
if (!state.loaded) return; if (!state.loaded) return;
empty.classList.toggle("hidden", !state.loaded || state.redirects.length > 0); 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. // Redirect card options menu actions: edit / change icon / delete.
document.querySelector("#redirect-list").addEventListener("click", async event => { 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; 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 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); } }); } 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); 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); }); document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
// --- Backup encryption password field: placeholder/visibility polish ------------- // --- Backup encryption password field: placeholder/visibility polish -------------
@@ -345,3 +345,214 @@ document.addEventListener("click", async event => {
renderConfigDriftCallout(); renderConfigDriftCallout();
} catch (error) { toast(error.message); } finally { button.disabled = false; } } 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();
};
+16 -9
View File
@@ -7,7 +7,7 @@
<title>Site Gateway</title> <title>Site Gateway</title>
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard."> <meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png"> <link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
<link rel="stylesheet" href="/styles.css?v=0.14.0"> <link rel="stylesheet" href="/styles.css?v=0.15.0">
</head> </head>
<!-- ================================================================ <!-- ================================================================
@@ -107,14 +107,14 @@
<section id="dashboard-view" class="dashboard-view" aria-label="Gateway dashboard"> <section id="dashboard-view" class="dashboard-view" aria-label="Gateway dashboard">
<div class="metric-grid"> <div class="metric-grid">
<button class="metric-card accent-green" data-target="hosted"><span class="metric-icon"></span><span class="metric-label">Hosted sites</span><strong id="dash-hosted-total">0</strong><span id="dash-hosted-detail">None configured</span></button> <button class="metric-card accent-green" data-target="hosted"><span class="metric-icon"></span><span class="metric-label">Hosted sites</span><strong id="dash-hosted-total">0</strong><span id="dash-hosted-detail">None configured</span></button>
<button class="metric-card accent-blue" data-target="proxies"><span class="metric-icon"></span><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button> <button class="metric-card accent-green" data-target="proxies"><span class="metric-icon"></span><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button>
<button class="metric-card accent-blue" data-target="certificates"><span class="metric-icon"></span><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button> <button class="metric-card accent-green" data-target="certificates"><span class="metric-icon"></span><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button>
</div> </div>
<div class="metric-strip"> <div class="metric-strip">
<button class="metric-chip accent-amber" data-target="redirects"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Redirect hosts</span><strong id="dash-redirect-total">0</strong></span></button> <button class="metric-chip accent-green" data-target="redirects"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Redirect hosts</span><strong id="dash-redirect-total">0</strong></span></button>
<button class="metric-chip accent-purple" data-target="streaming"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Streaming hosts</span><strong id="dash-stream-total">0</strong></span></button> <button class="metric-chip accent-green" data-target="streaming"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Streaming hosts</span><strong id="dash-stream-total">0</strong></span></button>
<div class="metric-chip" id="dash-attention-chip"><span class="chip-icon" id="dash-attention-icon"></span><span class="chip-copy"><span class="metric-label">Needs attention</span><strong id="dash-attention-total">0</strong><small id="dash-attention-detail">No current issues</small></span></div> <div class="metric-chip" id="dash-attention-chip"><span class="chip-icon" id="dash-attention-icon"></span><span class="chip-copy"><span class="metric-label">Needs attention</span><strong id="dash-attention-total">0</strong><small id="dash-attention-detail">No current issues</small></span></div>
<button class="metric-chip accent-blue" data-target="performance"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Throughput</span><strong id="dash-throughput-total">0</strong><small id="dash-throughput-detail">requests / min</small></span></button> <button class="metric-chip accent-green" data-target="performance"><span class="chip-icon"></span><span class="chip-copy"><span class="metric-label">Throughput</span><strong id="dash-throughput-total">0</strong><small id="dash-throughput-detail">requests / min</small></span></button>
</div> </div>
<div class="dashboard-columns"> <div class="dashboard-columns">
<section class="dashboard-panel health-panel status-healthy" id="health-panel"> <section class="dashboard-panel health-panel status-healthy" id="health-panel">
@@ -183,15 +183,22 @@
<div class="panel-heading"><div><p class="eyebrow">Trend</p><h2 id="performance-trend-title">Requests · last 6 hours</h2></div></div> <div class="panel-heading"><div><p class="eyebrow">Trend</p><h2 id="performance-trend-title">Requests · last 6 hours</h2></div></div>
<div class="log-filters"><label>Domain<select id="performance-host"><option value="">All domains</option></select></label><label>Range<select id="performance-range"><option value="1">Last hour</option><option value="3">Last 3 hours</option><option value="6" selected>Last 6 hours</option><option value="12">Last 12 hours</option><option value="24">Last 24 hours</option><option value="72">Last 3 days</option><option value="168">Last 7 days</option></select></label></div> <div class="log-filters"><label>Domain<select id="performance-host"><option value="">All domains</option></select></label><label>Range<select id="performance-range"><option value="1">Last hour</option><option value="3">Last 3 hours</option><option value="6" selected>Last 6 hours</option><option value="12">Last 12 hours</option><option value="24">Last 24 hours</option><option value="72">Last 3 days</option><option value="168">Last 7 days</option></select></label></div>
<p id="performance-summary" class="muted feature-note">No requests recorded yet. <span id="performance-last-checked">Not checked yet.</span></p> <p id="performance-summary" class="muted feature-note">No requests recorded yet. <span id="performance-last-checked">Not checked yet.</span></p>
<p class="chart-axis-unit">Requests</p>
<div class="performance-sparkline-wrap"> <div class="performance-sparkline-wrap">
<svg id="performance-sparkline" class="performance-sparkline" viewBox="0 0 600 140" preserveAspectRatio="none" aria-label="Request volume trend"></svg> <svg id="performance-sparkline" class="performance-sparkline" viewBox="0 0 600 140" preserveAspectRatio="none" aria-label="Request volume trend"></svg>
<div id="performance-tooltip" class="performance-tooltip hidden" aria-hidden="true"></div>
<div id="performance-sparkline-labels" class="performance-sparkline-labels"></div> <div id="performance-sparkline-labels" class="performance-sparkline-labels"></div>
</div> </div>
</section> </section>
<section class="dashboard-panel"> <section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Per-route</p><h2>Throughput by domain</h2></div></div> <div class="panel-heading"><div><p class="eyebrow">Per-route</p><h2>Throughput by domain</h2></div></div>
<p class="muted feature-note">Requests, error rate, and average response time for each configured domain.</p> <p class="muted feature-note">Requests, error rate, latency, bandwidth, and unique visitors for each configured domain, over the last 24 hours.</p>
<div class="table-wrap performance-table-wrap"><table class="performance-table"><thead><tr><th>Domain</th><th>Last hour</th><th>Last 24h</th><th>Avg. response</th></tr></thead><tbody id="performance-rows"></tbody></table></div> <div class="table-wrap performance-table-wrap"><table class="performance-table"><thead><tr><th>Domain</th><th>Last hour</th><th>Last 24h</th><th>Avg. response</th><th>p95 response</th><th>Data transferred</th><th>Unique visitors</th><th>Top paths</th></tr></thead><tbody id="performance-rows"></tbody></table></div>
</section>
<section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Outliers</p><h2 id="performance-slowest-title">Slowest requests</h2></div></div>
<p class="muted feature-note">The individual requests that took longest in the selected window and domain filter.</p>
<div id="performance-slowest" class="slowest-list"></div>
</section> </section>
</section> </section>
@@ -430,6 +437,6 @@
<div id="toast" class="toast" role="status"></div> <div id="toast" class="toast" role="status"></div>
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div> <div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
<!-- App scripts: core (app.js) then extended views/admin (features.js) --> <!-- App scripts: core (app.js) then extended views/admin (features.js) -->
<script src="/app.js?v=0.14.0" defer></script><script src="/features.js?v=0.14.0" defer></script> <script src="/app.js?v=0.15.0" defer></script><script src="/features.js?v=0.15.0" defer></script>
</body> </body>
</html> </html>
+68 -5
View File
@@ -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: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-green{--card-accent:var(--green)}
.metric-card.accent-blue{--card-accent:var(--blue)} .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,.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-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} .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{--card-accent:var(--green)}
.metric-chip.accent-green strong{color:var(--green)} .metric-chip.accent-green strong{color:var(--green)}
.metric-chip.accent-blue{--card-accent:var(--blue)} .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{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}
.dashboard-columns.lower.attention-clear{grid-template-columns:1fr} .dashboard-columns.lower.attention-clear{grid-template-columns:1fr}
.dashboard-jobs-slot:not(:empty){margin-top:18px} .dashboard-jobs-slot:not(:empty){margin-top:18px}
@@ -270,6 +276,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 .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{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-end{transform:translateX(-100%)}
.performance-sparkline-labels .time-label.time-label-mid{transform:translateX(-50%)}
.row-highlight{background:rgba(var(--green-rgb),.08)} .row-highlight{background:rgba(var(--green-rgb),.08)}
.user-head-actions{display:flex;align-items:center;gap:10px} .user-head-actions{display:flex;align-items:center;gap:10px}
[data-admin-panel="backups"]>.dashboard-panel{margin-top:var(--space-5)} [data-admin-panel="backups"]>.dashboard-panel{margin-top:var(--space-5)}
@@ -864,17 +871,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{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 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 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(1),.performance-table td:nth-child(1){width:22%}
.performance-table th:nth-child(2),.performance-table td:nth-child(2){width:20%;text-align:center} .performance-table th:nth-child(n+2),.performance-table td:nth-child(n+2){width:11.1%;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 .count-divider{color:var(--muted);margin:0 2px} .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{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} .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-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{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} .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 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} .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 +887,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} .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} #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)}
+328 -34
View File
@@ -64,6 +64,8 @@ const recentActivity = [];
const upstreamHealth = new Map(); const upstreamHealth = new Map();
const certificateStatusCache = new Map(); const certificateStatusCache = new Map();
const loginAttempts = new Map(); const loginAttempts = new Map();
const rateLimitBuckets = new Map();
let dockerSocketMounted = false;
let currentAuditActor = null; let currentAuditActor = null;
const probeFailures = { gateway: 0, http: 0, https: 0 }; const probeFailures = { gateway: 0, http: 0, https: 0 };
let iconCatalog = null; let iconCatalog = null;
@@ -146,6 +148,99 @@ function sessionUser(req) {
return user; 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 saveSites = async () => storage.saveCollection("sites", sites);
const saveProxies = async () => storage.saveCollection("proxies", proxies); const saveProxies = async () => storage.saveCollection("proxies", proxies);
const saveUsers = async () => storage.saveCollection("users", users); 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 // renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state
// (sites, proxies, redirects, streams, access lists, default site settings). // (sites, proxies, redirects, streams, access lists, default site settings).
function renderCaddyfile() { function caddyLoggingDirectives() {
const email = String(process.env.ACME_EMAIL || "").trim(); return [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
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", " }"]; // One builder per route kind, each returning the exact Caddyfile lines that route
lines.push("}", "", ":80 {", ...logging); // contributes. renderCaddyfile()'s loops and the "View Caddy config" endpoint both call
const defaultSite = settings.defaultSite || {}; // these, so the popout can never drift from the configuration Caddy actually runs.
if (defaultSite.mode === "abort") lines.push(" abort"); function renderHostedSiteBlock(site, logging = caddyLoggingDirectives()) {
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}`); return [`${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server", "}"];
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }"); }
lines.push("}"); function renderProxyBlock(proxy, logging = caddyLoggingDirectives()) {
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) { const lines = [`${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy)];
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
lines.push("}");
}
for (const proxy of proxies.filter(item => item.enabled && item.domain)) {
lines.push("", `${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy));
for (const location of proxy.locations || []) { for (const location of proxy.locations || []) {
lines.push(` ${location.stripPrefix ? "handle_path" : "handle"} ${location.path} {`, ...proxyBlock(location.target, location, " "), " }"); 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)); else lines.push(...proxyBlock(proxy.target, proxy));
if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`)); if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`));
lines.push("}"); 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}" : ""}`; 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`; 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 // syncCaddy -- applies the generated config to the running Caddy instance and
// records success/failure (gatewayError, lastGatewayReload) for the dashboard. // records success/failure (gatewayError, lastGatewayReload) for the dashboard.
async function syncCaddy() { async function syncCaddy() {
@@ -792,7 +968,7 @@ async function dashboardSnapshot() {
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.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 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 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"}.` }); 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 disk = await fsp.statfs(dataDir).catch(() => null);
const databaseIntegrity = storage.integrity(); const databaseIntegrity = storage.integrity();
return { return {
@@ -1012,7 +1188,7 @@ async function openBackup(filename, password = "") {
return { zip: new AdmZip(buffer), encrypted }; 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 safeType = type === "complete" ? "complete" : "configuration";
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `${prefix}-${stamp}.sgbackup`; const filename = `${prefix}-${stamp}.sgbackup`;
@@ -1036,6 +1212,22 @@ async function createBackup(type = "configuration", includeLogs = false, prefix
return { filename, path: destination, ...manifest, size: (await fsp.stat(destination)).size }; 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() { async function listBackups() {
const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup")); const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup"));
return Promise.all(names.map(async filename => { return Promise.all(names.map(async filename => {
@@ -1088,7 +1280,10 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
for (const site of sites.filter(item => item.enabled)) await startSite(site); 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); } } 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.`); 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) { } 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) { if (safetyBackup) {
try { await restoreBackup(safetyBackup.filename, "", false); recordActivity(`Restore of ${filename} failed; the pre-restore state was recovered.`, "error"); } 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}`; } catch (rollbackError) { error.message = `${error.message} Automatic rollback also failed: ${rollbackError.message}`; }
@@ -1119,6 +1314,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 app = express();
const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } }); const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } });
const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } }); const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } });
@@ -1173,6 +1371,7 @@ function pendingMfaUser(req) {
app.post("/api/login", async (req, res, next) => { app.post("/api/login", async (req, res, next) => {
try { try {
const key = req.ip || req.socket.remoteAddress || "unknown"; 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); 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." }); } if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
const username = String(req.body.username || "").trim().toLowerCase(); const username = String(req.body.username || "").trim().toLowerCase();
@@ -1244,6 +1443,7 @@ app.get("/_site-gateway/login", (req, res) => {
}); });
app.post("/_site-gateway/login", async (req, res, next) => { app.post("/_site-gateway/login", async (req, res, next) => {
try { 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 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) : "/"; 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`); 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 +1454,19 @@ app.post("/_site-gateway/login", async (req, res, next) => {
// --- First-run admin setup --------------------------------------------------------------------------------- // --- First-run admin setup ---------------------------------------------------------------------------------
app.use("/api", (req, res, next) => { 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); const user = sessionUser(req);
if (!user) return res.status(401).json({ error: "Please sign in." }); if (!user) return res.status(401).json({ error: "Please sign in." });
req.user = user; req.user = user;
@@ -1291,8 +1504,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 (!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." }); 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.password = await passwordRecord(newPassword);
req.user.updatedAt = new Date().toISOString(); rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`); await saveUsers(); recordActivity(`User “${req.user.username}” changed their password. Other sessions and API tokens were signed out.`);
issueSessionCookie(res, req.user); issueSessionCookie(res, req.user);
res.json({ ok: true }); res.json({ ok: true });
} catch (error) { next(error); } } catch (error) { next(error); }
@@ -1326,8 +1539,9 @@ app.post("/api/account/mfa/disable", async (req, res, next) => {
try { try {
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); 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.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = [];
req.user.updatedAt = new Date().toISOString(); rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning"); 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 }); res.json({ ok: true });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -1344,7 +1558,7 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
}); });
// --- Config, Users, Audit log, Groups, Access List <-> Group assignment -------------------------------------- // --- 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) => { app.post("/api/gateway/resync", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { try {
@@ -1356,6 +1570,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/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." }));
app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." }));
// --- 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) => { app.post("/api/users", async (req, res, next) => {
try { try {
const username = String(req.body.username || "").trim().toLowerCase(); const username = String(req.body.username || "").trim().toLowerCase();
@@ -1390,12 +1639,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." }); 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; user.displayName = displayName;
} }
let invalidateSessions = nextStatus !== "active";
if (req.body.password !== undefined) { if (req.body.password !== undefined) {
const password = String(req.body.password); const password = String(req.body.password);
if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." });
user.password = await passwordRecord(password); 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)); res.json(publicUser(user));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -1419,9 +1673,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) 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." }); 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.mfaEnabled = false; user.mfaSecret = null; user.mfaPendingSecret = null; user.mfaRecoveryCodes = [];
user.updatedAt = new Date().toISOString(); rotateSessionVersion(user);
await saveUsers(); 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 }); res.json({ ok: true });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -1467,6 +1722,16 @@ app.get("/api/support-report", async (req, res, next) => {
}); });
// --- Upstream health, Logs, and Performance (request throughput/trend) endpoints -------------------------------- // --- 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.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy)));
app.post("/api/upstreams/check", async (req, res, next) => { app.post("/api/upstreams/check", async (req, res, next) => {
try { res.json(await checkAllProxies()); } try { res.json(await checkAllProxies()); }
@@ -1486,17 +1751,34 @@ app.get("/api/performance", (req, res, next) => {
const bucketMinutes = hours > 24 ? 60 : 15; const bucketMinutes = hours > 24 ? 60 : 15;
const breakdownByHost = new Map(); 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 }); } 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({ res.json({
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
liveRequests: storage.performanceLiveCount(60), 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), 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() hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort()
}); });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
// --- Icon search and per-entity icon upload/URL/removal ----------------------------------------------------------- // --- 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) => { app.get("/api/icons/search", async (req, res, next) => {
try { try {
const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80); const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80);
@@ -1929,6 +2211,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"; 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) }; 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.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) { if (req.body.certificateHealth) {
const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120); const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120);
@@ -1952,6 +2235,7 @@ app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.
// --- Backups: list / create / import / download / restore / delete ----------------------------------------------------- // --- Backups: list / create / import / download / restore / delete -----------------------------------------------------
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." }));
app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } }); app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
app.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) => { 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); } 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 +2245,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"); 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 }); 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)); 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); } } 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) => { app.get("/api/backups/:filename/download", async (req, res, next) => {
@@ -1971,7 +2257,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); } 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) => { 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, ""); } 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 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 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 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. */ } try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
const timestamp = now(); const timestamp = now();
@@ -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>=? THEN 1 ELSE 0 END) AS hourRequests,
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors, SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs, AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
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, COUNT(*) AS dayRequests,
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors, SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
AVG(duration_ms) AS dayAvgMs AVG(duration_ms) AS dayAvgMs,
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!='' FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
GROUP BY host ORDER BY dayRequests DESC 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) { function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString(); 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 bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
const windowMs = Math.max(1, Number(hours) || 6) * 3600000; const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
const cutoff = new Date(Date.now() - windowMs).toISOString(); const cutoff = new Date(Date.now() - windowMs).toISOString();
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host); const rows = db.prepare(`SELECT at,status FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const buckets = new Map(); 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 startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
const points = []; 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; 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 pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; } function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
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 })); } 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}`); } } 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) { 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; } 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(", ")}`); } 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() };
} }