Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 968066d39a | |||
| b72afdcee3 | |||
| 43ba59b302 | |||
| 00990d5008 | |||
| e0463525ef | |||
| 5417d5eeed | |||
| 2890be6129 | |||
| 964b1dd2de | |||
| 26e2660a03 | |||
| 9c20253fa4 | |||
| 49d8bd83e6 | |||
| 7109967f67 | |||
| e26c1a3dec | |||
| f51067bceb | |||
| 31e50d0e14 | |||
| 98b79042bf | |||
| 72afecd539 | |||
| 180280791a | |||
| 753fb33d52 | |||
| 7846342412 | |||
| 2d0e9989ed | |||
| 92f3430892 | |||
| 5c84e6cf73 |
@@ -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.11.101-62E6A7">
|
<img alt="Version" src="https://img.shields.io/badge/version-0.14.0-62E6A7">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||||
@@ -42,7 +42,7 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
|
|||||||
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
||||||
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
||||||
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
||||||
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes.
|
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
|
||||||
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
||||||
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
||||||
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
||||||
|
|||||||
+27
-5
@@ -2,7 +2,26 @@
|
|||||||
|
|
||||||
## Current release status
|
## Current release status
|
||||||
|
|
||||||
`v0.11.101` is a stable, day-to-day release. The product has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
|
`v0.12.0` marks a shift from the earlier per-fix `0.11.x` patch numbering to ordinary semantic versioning going forward — a minor bump for a real batch of changes, a patch bump for a targeted fix, rather than incrementing the same trailing number for every single change regardless of size. The product itself has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
|
||||||
|
|
||||||
|
`v0.13.0` is a real batch under that same convention, not a targeted fix, even though none of it changes what the app *does*: the in-app light theme has been removed entirely (the app is dark-only now, including the two visitor-facing themed pages -- the default-site 404/welcome/custom-HTML page and the Access-List sign-in page, both previously following the visitor's OS light/dark preference and now fixed dark for consistency with the rest of the app), the full color/spacing/radius design-token system begun in v0.12.0 has been completed (zero hardcoded color literals remain anywhere outside the token definitions), and `styles.css` has been restructured into commented, page-aligned sections matching the convention already used in `app.js`/`features.js`/`server.js`. A handful of small pre-existing bugs (a duplicate CSS custom property, some dead/duplicate rules, a decorative background glow that rendered incorrectly at certain aspect ratios) were also found and fixed along the way.
|
||||||
|
|
||||||
|
`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup, and scopes out (but does not yet ship) a larger set of previously-discussed features tracked below as follow-up work.
|
||||||
|
|
||||||
|
- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list and a "Resync now" callout appears under Administration \u2192 Default site, both driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
|
||||||
|
- **Backup-encryption readiness** — the "Encrypt scheduled backups" checkbox no longer lets you configure something that will silently fail later. `/api/config` now reports whether the `BACKUP_PASSWORD` environment variable is actually set; the checkbox is disabled with an explanatory message when it isn't, and if it was previously saved as enabled and `BACKUP_PASSWORD` has since been removed, it shows a distinct warning instead of failing quietly at the next scheduled run.
|
||||||
|
- **Dialog cleanup** \u2014 every themed popout dialog's redundant "\u00d7" close button (in the dialog-heading row) has been removed app-wide; each dialog already has a working Cancel/Close button in its actions row, so this is pure de-duplication with no loss of function. New dialogs are expected to follow this pattern going forward.
|
||||||
|
|
||||||
|
### Follow-up work carried from this release's planning
|
||||||
|
|
||||||
|
A larger feature set was scoped for `v0.14.0` and intentionally deferred rather than shipped partially-verified. These remain on the roadmap for a future release:
|
||||||
|
|
||||||
|
- **REST API with issuable tokens** \u2014 admin-issued bearer tokens (full or read-only scope) for scripting against the Site Gateway API outside the browser session, bound to the issuing user's session version so a password reset/deactivation revokes them automatically.
|
||||||
|
- **Backup/restore history** \u2014 a durable, database-backed history of every backup, restore, and deletion (including failed attempts), shown as a human-readable timeline that never displays raw backup filenames.
|
||||||
|
- **Docker container picker** \u2014 an opt-in integration (gated on the Docker socket being mounted and readable) that lets Proxy/Streaming targets be picked from the host's running containers instead of typed by hand, using each container's Docker DNS name.
|
||||||
|
- **"View Caddy config" popout** \u2014 a read-only, prettified view of the exact Caddy configuration block generated for a given site, proxy, or redirect, built from the same code path that generates the real deployed config so it can never drift from it.
|
||||||
|
- **Performance screen overhaul** \u2014 clock-aligned time-axis labels, a y-axis unit, hover tooltips with error counts, p95 latency, bandwidth and unique-visitor columns, a 4xx/5xx-colored error breakdown, top-paths-per-host, and a slowest-requests panel.
|
||||||
|
- **Dashboard tile color unification** \u2014 normalizing all "normal count" tiles to a shared green baseline that reacts to warning/danger states the same way the existing Needs Attention tile does.
|
||||||
|
|
||||||
## Product direction
|
## Product direction
|
||||||
|
|
||||||
@@ -23,7 +42,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
|
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
|
||||||
- Groups, used to grant Access List membership without managing users one by one.
|
- Groups, used to grant Access List membership without managing users one by one.
|
||||||
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
|
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
|
||||||
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it.
|
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it, plus an administrator-side override (Administration → Users → “•••” → Disable 2FA) for a user who's locked out with no recovery codes left. Logged to the Audit log.
|
||||||
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
|
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
|
||||||
|
|
||||||
### Certificates and TLS
|
### Certificates and TLS
|
||||||
@@ -36,9 +55,10 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
|
|
||||||
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
|
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
|
||||||
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
|
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
|
||||||
- Performance view with request throughput, response times, and per-route breakdowns.
|
- Performance view with request throughput, response times, and per-route breakdowns — the host filter applies to the throughput table as well as the trend chart, average response times display in seconds once they pass 1000ms, and per-domain error counts open a themed breakdown by status code.
|
||||||
- Rotating access and activity logs.
|
- Rotating access and activity logs.
|
||||||
- Update-available banner when a newer image is deployed.
|
- Update-available banner when a newer image is deployed.
|
||||||
|
- A redacted support-report export exists (version, config health, certificate readiness, upstream checks, recent events) but its UI entry point is currently hidden pending a readability rewrite of the report's output format.
|
||||||
|
|
||||||
### Data and operations
|
### Data and operations
|
||||||
|
|
||||||
@@ -49,14 +69,16 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
### Brand and docs
|
### Brand and docs
|
||||||
|
|
||||||
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
||||||
- Integrated, searchable in-app documentation covering every configurable field, including 2FA and the update-notification banner.
|
- A sitewide design-token system (colors, spacing, radius, and type scale defined once and reused everywhere) underpins the interface, so new UI stays visually consistent by default.
|
||||||
|
- Integrated, searchable in-app documentation covering every configurable field, including 2FA (self-service and the administrator override) and the update-notification banner.
|
||||||
|
- Toast notifications are color-coded — error toasts render distinctly from success/neutral ones, using the same token-driven theming as the rest of the interface.
|
||||||
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
|
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
Roughly in priority order:
|
Roughly in priority order:
|
||||||
|
|
||||||
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain, plus a redacted support-report export.
|
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain.
|
||||||
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
||||||
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
||||||
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.11.102",
|
"version": "0.14.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",
|
||||||
|
|||||||
+44
-44
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// app.js -- core client application: state, API helper, theme, dashboard,
|
// app.js -- core client application: state, API helper, dashboard,
|
||||||
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
|
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
|
||||||
// edit/icon/user/account/MFA), and every event listener for those areas. The
|
// edit/icon/user/account/MFA), and every event listener for those areas. The
|
||||||
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
|
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
|
||||||
@@ -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 };
|
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: {} };
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -19,19 +19,6 @@ const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]
|
|||||||
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
|
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
|
||||||
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
|
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
|
||||||
|
|
||||||
// --- Theme (light/dark/system) -------------------------------------------------
|
|
||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
|
|
||||||
function applyTheme(preference) {
|
|
||||||
const effective = preference === "system" ? (systemTheme.matches ? "dark" : "light") : preference;
|
|
||||||
document.documentElement.dataset.theme = effective;
|
|
||||||
document.querySelector('meta[name="theme-color"]').content = effective === "dark" ? "#08101d" : "#f3f6fa";
|
|
||||||
}
|
|
||||||
const savedTheme = localStorage.getItem("webserver-theme") || "system";
|
|
||||||
$("#theme-select").value = savedTheme; applyTheme(savedTheme);
|
|
||||||
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
|
|
||||||
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
|
|
||||||
|
|
||||||
// --- API helper ------------------------------------------------------------------
|
// --- API helper ------------------------------------------------------------------
|
||||||
|
|
||||||
async function api(url, options = {}) {
|
async function api(url, options = {}) {
|
||||||
@@ -44,7 +31,7 @@ async function api(url, options = {}) {
|
|||||||
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
|
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
|
||||||
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
||||||
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
||||||
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
||||||
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
||||||
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
|
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
|
||||||
|
|
||||||
@@ -337,9 +324,11 @@ function renderPerformance() {
|
|||||||
$("#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" />` : "";
|
||||||
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>';
|
||||||
const routes = data.routes || [];
|
const routes = (data.routes || []).filter(route => !selected || route.host === selected);
|
||||||
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
|
state.performanceErrorBreakdowns = {};
|
||||||
$("#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)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
|
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; };
|
||||||
|
const formatAvgMs = ms => ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
|
||||||
|
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatAvgMs(route.dayAvgMs)}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||||
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,11 +346,11 @@ function renderUsers() {
|
|||||||
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
|
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
|
||||||
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
|
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
|
||||||
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
|
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
|
||||||
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
|
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf && user.mfaEnabled ? `<button data-user-action="mfa-disable">Disable 2FA</button>` : ""}${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
|
||||||
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
|
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
|
||||||
}).join("") : '<p class="quiet-state">No users found.</p>';
|
}).join("") : '<p class="quiet-state">No users found.</p>';
|
||||||
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
|
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
|
||||||
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message, "error"); } }); old.replaceWith(select); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -413,7 +402,7 @@ function render() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!management) {
|
if (!management) {
|
||||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
||||||
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
||||||
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||||
@@ -474,7 +463,7 @@ async function boot() {
|
|||||||
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
||||||
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
||||||
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
||||||
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message, "error")); }, 30000);
|
||||||
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,20 +488,24 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
|
|||||||
|
|
||||||
// --- Dashboard actions: run certificate check, download support report, jump to
|
// --- Dashboard actions: run certificate check, download support report, jump to
|
||||||
// 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); } 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)); } });
|
$("#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")); } });
|
||||||
|
|
||||||
// --- 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"); }); }
|
||||||
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); } }));
|
||||||
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
|
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); });
|
||||||
|
|
||||||
// --- Logs & Performance filter controls -----------------------------------------------------
|
// --- Logs & Performance filter controls -----------------------------------------------------
|
||||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
|
|
||||||
|
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------
|
||||||
|
function showErrorBreakdown(host, total, breakdown) { let dialog = document.querySelector("#error-breakdown-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); } const rows = breakdown.map(item => `<div class="error-breakdown-row"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join(""); dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p><div class="error-breakdown-list">${rows}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`; dialog.showModal(); }
|
||||||
|
$("#performance-rows").addEventListener("click", event => { const button = event.target.closest("[data-error-host]"); if (!button) return; const entry = state.performanceErrorBreakdowns[button.dataset.errorHost]; if (!entry) return; showErrorBreakdown(button.dataset.errorHost, entry.total, entry.breakdown); });
|
||||||
$("#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);
|
||||||
@@ -534,7 +527,7 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos
|
|||||||
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
||||||
|
|
||||||
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
||||||
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
|
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error")));
|
||||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||||
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||||
|
|
||||||
@@ -570,7 +563,7 @@ $("#site-grid").addEventListener("click", async event => {
|
|||||||
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
||||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
||||||
closeMenus();
|
closeMenus();
|
||||||
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } }
|
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
|
||||||
if (action === "settings") openSettings(kind, card.dataset.id);
|
if (action === "settings") openSettings(kind, card.dataset.id);
|
||||||
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(); }
|
||||||
@@ -587,7 +580,7 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => {
|
|||||||
|
|
||||||
// --- 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); } 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; });
|
||||||
|
|
||||||
|
|
||||||
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
|
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
|
||||||
@@ -650,11 +643,18 @@ $("#user-list").addEventListener("click", async event => {
|
|||||||
closeMenus();
|
closeMenus();
|
||||||
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
|
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
|
||||||
}
|
}
|
||||||
|
if (button.dataset.userAction === "mfa-disable") {
|
||||||
|
closeMenus();
|
||||||
|
if (!await themedUserConfirm(`Disable two-factor authentication for “${user.username}”? They’ll be able to sign in with just their password until they set it up again.`, "Disable 2FA")) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try { await api(`/api/users/${user.id}/mfa/disable`, { method: "POST" }); await loadFeatureView(); toast("Two-factor authentication disabled."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (button.dataset.userAction === "delete") {
|
if (button.dataset.userAction === "delete") {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
|
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; }
|
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
@@ -662,7 +662,7 @@ $("#user-list").addEventListener("click", async event => {
|
|||||||
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
|
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
|
||||||
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||||
await loadFeatureView(); toast("User updated.");
|
await loadFeatureView(); toast("User updated.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -683,10 +683,10 @@ window.addEventListener("hashchange", () => {
|
|||||||
if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
|
if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
|
||||||
state.view = location.hash.slice(1) || "overview";
|
state.view = location.hash.slice(1) || "overview";
|
||||||
render();
|
render();
|
||||||
loadFeatureView().catch(error => toast(error.message));
|
loadFeatureView().catch(error => toast(error.message, "error"));
|
||||||
});
|
});
|
||||||
|
|
||||||
boot().catch(error => toast(error.message));
|
boot().catch(error => toast(error.message, "error"));
|
||||||
|
|
||||||
|
|
||||||
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
|
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
|
||||||
@@ -762,7 +762,7 @@ $("#account-mfa-enable").addEventListener("click", async () => {
|
|||||||
$("#mfa-setup-error").textContent = "";
|
$("#mfa-setup-error").textContent = "";
|
||||||
$("#mfa-setup-confirm-form").reset();
|
$("#mfa-setup-confirm-form").reset();
|
||||||
$("#mfa-setup-dialog").showModal();
|
$("#mfa-setup-dialog").showModal();
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
||||||
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
||||||
@@ -775,7 +775,7 @@ $("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
|||||||
state.user.mfaEnabled = true;
|
state.user.mfaEnabled = true;
|
||||||
renderAccount();
|
renderAccount();
|
||||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
$("#mfa-recovery-dialog").showModal();
|
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
|
||||||
toast("Two-factor authentication enabled.");
|
toast("Two-factor authentication enabled.");
|
||||||
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
||||||
});
|
});
|
||||||
@@ -791,7 +791,7 @@ $("#account-mfa-disable").addEventListener("click", async () => {
|
|||||||
state.user.mfaEnabled = false;
|
state.user.mfaEnabled = false;
|
||||||
renderAccount();
|
renderAccount();
|
||||||
toast("Two-factor authentication disabled.");
|
toast("Two-factor authentication disabled.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
$("#account-mfa-recovery").addEventListener("click", async () => {
|
$("#account-mfa-recovery").addEventListener("click", async () => {
|
||||||
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
||||||
@@ -799,7 +799,7 @@ $("#account-mfa-recovery").addEventListener("click", async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
$("#mfa-recovery-dialog").showModal();
|
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
|
||||||
toast("Recovery codes regenerated. Your old codes no longer work.");
|
toast("Recovery codes regenerated. Your old codes no longer work.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-12
File diff suppressed because one or more lines are too long
+19
-21
File diff suppressed because one or more lines are too long
+757
-159
File diff suppressed because one or more lines are too long
+62
-3
File diff suppressed because one or more lines are too long
@@ -1,11 +1,3 @@
|
|||||||
// ============================================================================
|
|
||||||
// storage.js -- SQLite persistence layer for Site Gateway.
|
|
||||||
// Owns the on-disk database, one-time legacy JSON migration, and every
|
|
||||||
// read/write function server.js uses to load and save app data (sites,
|
|
||||||
// proxies, redirects, streams, access lists, users, groups, settings,
|
|
||||||
// activity/audit logs, and request performance data).
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import fsp from "node:fs/promises";
|
import fsp from "node:fs/promises";
|
||||||
@@ -13,10 +5,6 @@ import path from "node:path";
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
|
|
||||||
|
|
||||||
// Legacy pre-SQLite storage: each entity kind used to live in its own JSON
|
|
||||||
// file under the data directory. entityTables maps the same kinds to their
|
|
||||||
// current SQLite table names.
|
|
||||||
export const LOCAL_INSTANCE_ID = "local";
|
export const LOCAL_INSTANCE_ID = "local";
|
||||||
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"];
|
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"];
|
||||||
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
||||||
@@ -24,11 +12,6 @@ const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects:
|
|||||||
|
|
||||||
function now() { return new Date().toISOString(); }
|
function now() { return new Date().toISOString(); }
|
||||||
|
|
||||||
// One-time safety snapshot taken before migrating legacy JSON files into
|
|
||||||
// SQLite: zips up the JSON files plus related data directories (sites,
|
|
||||||
// icons, default-site, certificates) into a timestamped .sgbackup archive
|
|
||||||
// so the pre-migration state is always recoverable.
|
|
||||||
|
|
||||||
async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
|
async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
|
||||||
const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name)));
|
const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name)));
|
||||||
if (!present.length) return null;
|
if (!present.length) return null;
|
||||||
@@ -48,11 +31,6 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
|
|||||||
manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName);
|
manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName);
|
||||||
manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")]));
|
manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")]));
|
||||||
zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2)));
|
zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2)));
|
||||||
|
|
||||||
// openStorage -- the single entry point server.js calls at boot. Ensures the
|
|
||||||
// data directories and SQLite database exist, runs schema setup and the
|
|
||||||
// legacy JSON migration (if needed), and returns the full set of
|
|
||||||
// read/write functions used throughout the app.
|
|
||||||
const filename = `pre-sqlite-migration-${stamp}.sgbackup`;
|
const filename = `pre-sqlite-migration-${stamp}.sgbackup`;
|
||||||
await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 });
|
await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 });
|
||||||
return { filename, snapshotDir };
|
return { filename, snapshotDir };
|
||||||
@@ -61,13 +39,6 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
|
|||||||
export async function openStorage(dataDir, backupsDir) {
|
export async function openStorage(dataDir, backupsDir) {
|
||||||
const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite");
|
const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite");
|
||||||
await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]);
|
await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]);
|
||||||
|
|
||||||
// --- Schema setup -----------------------------------------------------
|
|
||||||
// Core entity tables (hosted sites, proxy hosts, redirect hosts, stream
|
|
||||||
// hosts, access lists, users, groups) each store their record as a JSON
|
|
||||||
// payload column, plus supporting tables for access-list assignments,
|
|
||||||
// settings, audit/activity logs, and raw request (access) events used
|
|
||||||
// for performance reporting.
|
|
||||||
const isNew = !fs.existsSync(databasePath);
|
const isNew = !fs.existsSync(databasePath);
|
||||||
const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null;
|
const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null;
|
||||||
const db = new DatabaseSync(databasePath);
|
const db = new DatabaseSync(databasePath);
|
||||||
@@ -90,31 +61,18 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id);
|
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id);
|
||||||
CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id);
|
CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id);
|
||||||
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
|
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
|
||||||
// Forward-compatible column add for databases created before "category"
|
|
||||||
// existed on activity_events; a no-op once the column is already there.
|
|
||||||
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
|
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
|
||||||
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
|
||||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||||
|
|
||||||
// --- Core entity read/write --------------------------------------------
|
|
||||||
// transaction() wraps a block of statements in BEGIN IMMEDIATE/COMMIT,
|
|
||||||
// rolling back on any thrown error.
|
|
||||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||||
// loadCollection -- reads every record of one entity kind (sites, proxies,
|
|
||||||
// redirects, streams, access_lists, users, groups) for an instance.
|
|
||||||
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
||||||
// refreshAssignments -- rebuilds the access_assignments table (which route
|
|
||||||
// is protected by which Access List) from the current hosted/proxy/
|
|
||||||
// redirect payloads. Called after any save that could change accessListId.
|
|
||||||
`);
|
`);
|
||||||
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
|
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
|
||||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
|
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
|
||||||
// saveCollection -- replaces (or, for access_lists, upserts/prunes) all
|
|
||||||
// records of one entity kind for an instance, inside a single transaction.
|
|
||||||
|
|
||||||
function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }
|
function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }
|
||||||
function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); }
|
function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); }
|
||||||
@@ -133,37 +91,17 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
const created = value.createdAt || now(), stored = { ...value, instanceId };
|
const created = value.createdAt || now(), stored = { ...value, instanceId };
|
||||||
if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom"));
|
if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom"));
|
||||||
insert.run(value.id, instanceId, JSON.stringify(stored), created, now());
|
insert.run(value.id, instanceId, JSON.stringify(stored), created, now());
|
||||||
|
|
||||||
// --- Settings -----------------------------------------------------------
|
|
||||||
}
|
}
|
||||||
if (kind === "access_lists") {
|
if (kind === "access_lists") {
|
||||||
const keep = new Set(values.map(value => value.id));
|
const keep = new Set(values.map(value => value.id));
|
||||||
|
|
||||||
// --- Database health ------------------------------------------------------
|
|
||||||
for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id);
|
for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audit & activity logs -----------------------------------------------
|
|
||||||
// recordAudit -- administrative/security audit trail (who did what).
|
|
||||||
if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId);
|
if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId);
|
||||||
// recordActivity -- user-facing activity feed (what happened), auto-
|
|
||||||
// categorized into certificate/security/activity based on the message text.
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Access (request) events, powering Logs and Performance ---------------
|
|
||||||
// recordAccessEvents -- bulk-inserts raw request log lines tailed from
|
|
||||||
// Caddy's access log; ON IGNORE + UNIQUE(instance_id,source) makes re-
|
|
||||||
// ingesting the same log line idempotent.
|
|
||||||
function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; }
|
function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; }
|
||||||
function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); }
|
function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); }
|
||||||
// performanceLiveCount -- request count within the last windowSeconds,
|
|
||||||
// used for the "live requests" figure on the dashboard.
|
|
||||||
function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); }
|
function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); }
|
||||||
// performanceRoutes -- per-domain request/error/avg-response-time totals
|
|
||||||
// for the last hour and last 24 hours; backs the "Throughput by domain"
|
|
||||||
// table on the Performance page. A domain only appears here if it has
|
|
||||||
// at least one request within the last 24 hours (the dayCutoff filter).
|
|
||||||
function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); }
|
function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); }
|
||||||
function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); }
|
function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); }
|
||||||
function listActivity(limit = 100, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT message,status,category,created_at AS at FROM activity_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 100, 500))); }
|
function listActivity(limit = 100, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT message,status,category,created_at AS at FROM activity_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 100, 500))); }
|
||||||
@@ -177,9 +115,6 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
||||||
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
||||||
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
||||||
// performanceErrorBreakdown -- per-domain, per-status-code error counts
|
|
||||||
// over the last 24 hours; feeds the error-breakdown detail shown per row
|
|
||||||
// in the Performance table (top statuses per host).
|
|
||||||
COUNT(*) AS dayRequests,
|
COUNT(*) AS dayRequests,
|
||||||
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
||||||
AVG(duration_ms) AS dayAvgMs
|
AVG(duration_ms) AS dayAvgMs
|
||||||
@@ -188,9 +123,6 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
||||||
}
|
}
|
||||||
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
||||||
// performanceTrend -- bucketed request counts over a configurable window
|
|
||||||
// (default 6 hours, 15-minute buckets), optionally filtered to one host;
|
|
||||||
// backs the "Requests" trend chart on the Performance page.
|
|
||||||
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT host, status, COUNT(*) AS count
|
SELECT host, status, COUNT(*) AS count
|
||||||
@@ -204,29 +136,11 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
||||||
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
||||||
const buckets = new Map();
|
const buckets = new Map();
|
||||||
|
|
||||||
// --- Log retention / pruning ----------------------------------------------
|
|
||||||
// pruneEvents -- deletes access/activity/audit rows older than the
|
|
||||||
// configured retention policy (per category: access, activity, certificate,
|
|
||||||
// security, audit), returning how many rows were removed per category.
|
|
||||||
// Used by both the manual "prune now" action and the scheduled job.
|
|
||||||
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
|
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
|
||||||
// previewPruneEvents -- same policy/cutoffs as pruneEvents but read-only;
|
|
||||||
// used to show "this will remove N records" before the user confirms.
|
|
||||||
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
||||||
const points = [];
|
const points = [];
|
||||||
|
|
||||||
// --- Backups ---------------------------------------------------------------
|
|
||||||
// backupTo -- writes a consistent point-in-time copy of the SQLite database
|
|
||||||
// to `filename` using VACUUM INTO (safe to run against a live database).
|
|
||||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
|
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
|
||||||
return points;
|
return points;
|
||||||
|
|
||||||
// --- One-time legacy JSON -> SQLite migration --------------------------------
|
|
||||||
// Runs only when the database file didn't exist yet (isNew). Reads any
|
|
||||||
// legacy *.json files found in the data directory, inserts their records
|
|
||||||
// into the new SQLite tables inside a transaction, and rolls the whole
|
|
||||||
// database file back if anything fails partway through.
|
|
||||||
}
|
}
|
||||||
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
||||||
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
||||||
@@ -246,11 +160,6 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const settingsFile = path.join(dataDir, "settings.json");
|
const settingsFile = path.join(dataDir, "settings.json");
|
||||||
|
|
||||||
// --- One-off cleanup of a previously confusing error message ---------------
|
|
||||||
// humanizeGatewayErrors -- rewrites a specific raw Caddy error string that
|
|
||||||
// used to appear verbatim in the activity/audit logs into a plain-language
|
|
||||||
// explanation. Runs at boot so existing log rows get the friendlier text too.
|
|
||||||
if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp);
|
if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp);
|
||||||
refreshAssignments(LOCAL_INSTANCE_ID);
|
refreshAssignments(LOCAL_INSTANCE_ID);
|
||||||
}); } catch (error) {
|
}); } catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user