Compare commits

...

15 Commits

Author SHA1 Message Date
marvin bf93c009f0 Round Network stat's rate before formatting to fix layout-breaking decimals 2026-09-19 15:50:52 -04:00
marvin 9b80114e5b Fix DATA_DIR_LIMIT_GB comparing against whole-volume usage instead of Site Gateway's own footprint 2026-09-19 15:43:22 -04:00
marvin 813dde1f80 Add System tab hero panel auto-refresh and DATA_DIR_LIMIT_GB disk allowance 2026-09-19 15:38:28 -04:00
marvin 8b48e0db6c Fix CPU quota denominator and swap display on the System hero panel 2026-09-19 15:23:03 -04:00
marvin 992215151d Add System tab hero panel: CPU/memory/swap/disk/network + throughput 2026-09-19 14:46:47 -04:00
marvin b95a20942e Match API Access summary bar height to every other tab's summary bar (v0.16.28) 2026-09-19 14:04:54 -04:00
marvin 16dbbfec4e Fix API summary bar height and Pick container alignment for real (v0.16.27) 2026-09-19 13:50:46 -04:00
marvin 7e8e604df3 Shorten Docker socket helper text, fix Pick container alignment, fix summary bar wrapping (v0.16.26) 2026-09-19 13:26:41 -04:00
marvin 79fd947adb Parallelize filesystem walks across System/Certificates and remove duplicate cert scans (v0.16.25) 2026-09-19 12:55:43 -04:00
marvin 496716d4b2 Auto-reconnect and reload after a successful restart (v0.16.24) 2026-09-19 12:50:35 -04:00
marvin 5ac088025a Fix Restart application button doing nothing after confirmation (v0.16.23) 2026-09-19 12:40:40 -04:00
marvin ededa5a76f Fix Docker socket detection when the socket is group-owned, not world-readable (v0.16.22) 2026-09-19 09:26:23 -04:00
marvin 98b0884dcc Give API tokens full tile parity (icons, menu, hide-revoked); fix Groups icon bug (v0.16.21) 2026-09-18 22:51:27 -04:00
marvin aeff4d5773 Align frontend with backend role enforcement; correct role documentation (v0.16.20) 2026-09-18 22:40:58 -04:00
marvin 3df8f5db7f Move API token creation to shared header, expand docs, update README (v0.16.19) 2026-09-18 22:13:55 -04:00
10 changed files with 441 additions and 70 deletions
+4 -2
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.18-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.33-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
@@ -44,9 +44,10 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
- **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, 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.
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
- **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.
- **Performance and logs** — request throughput, response times, and rotating access/activity logs per host.
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page with environment/integration status, gateway sync, scheduled jobs, and storage usage.
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
@@ -93,6 +94,7 @@ Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443.
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
| `SITE_PORT_MIN` / `SITE_PORT_MAX` | `9000` / `9099` | Direct-LAN port range Hosted Sites can bind to |
| `DATA_DIR` | `/data` | Persistent state location |
| `DATA_DIR_LIMIT_GB` | empty | Optional display-only allowance for the System tab's Disk stat (e.g. a smaller dedicated share); usage/free space still come from the real volume |
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
| `PUID` / `PGID` | `1000` / `1000` | User/group the container writes files as (Unraid: `99`/`100`) |
| `ACME_EMAIL` | empty | Optional certificate account email |
+30
View File
@@ -162,3 +162,33 @@ Roughly in priority order:
`v0.16.17` fixes the Backup type helper text showing both the Complete and Configuration-only explanations stacked on top of each other on page load or refresh, instead of just the one matching the currently selected option. Root cause: the help text only ever updated on the select's `change` event -- but `renderBackups()` sets the select's value from saved settings on every render without firing a `change` event, so the static placeholder text (which briefly held both sentences as a v0.16.16 authoring mistake) never got replaced until you manually touched the dropdown. Factored the text-selection logic into its own function and call it both on `change` and every time `renderBackups()` runs, so it always matches the select's actual current value.
`v0.16.18` reworks the API Access tab to match the Users and Groups tabs' layout instead of the old plain data-row list: tokens are now shown as tiles in the same card grid Hosted Sites/Users/Groups use, and a stat bar above them breaks down Active/Revoked and Full access/Read-only counts at a glance. No behavior changed -- Revoke still works the same way it always has (a one-way action; there is no re-enable, since a revoked token's secret is treated as compromised). An earlier idea of adding an enable/disable toggle was dropped once it became clear that would require adding real token-reactivation support on the backend, a deliberate security-posture change rather than a layout fix.
`v0.16.19` finishes the API Access tab's alignment with Users and Groups: the "Create token" button now lives in the shared top-right header button used by every other create action instead of its own row inside the panel, and the panel-heading text ("Programmatic access / API access tokens / Issue bearer tokens...") has been removed the same way it was for Groups in v0.16.15, since the tab button's own label already says what the section is -- the stat bar is now the first thing in the panel. Also walked the in-app Documentation view and brought it current with everything shipped since it was last substantively updated: added a full API Access section (creating a token, scope, expiry, the one-time reveal, revoking, and automatic revocation when an issuing administrator's password changes or account is disabled), corrected the Performance section's per-route table description to drop the removed per-row error-count badge and instead document the pinned column headers and the "Not configured" chip added in v0.16.16, and added an API Access entry to the documentation sidebar's contents list.
`v0.16.20` audits role enforcement across the app after a run of Administration changes and fixes three places where the frontend showed a control the backend would actually reject for Standard Users and Viewers: the Dashboard's "Resync now" button (Needs Attention drift tile) and the Certificates page's "Run certificate check" button are now hidden for anyone who isn't an administrator, since both call administrator-only endpoints. The Access List editor's "Allowed groups" section -- previously always rendered with an empty `state.groups`, so a Standard User just saw a false "No groups have been created yet." -- now shows an accurate note pointing to an administrator instead, both when creating a new Access List and editing an existing one. Also corrected the in-app documentation: the Users & Groups role summary previously said Viewer "can inspect everything," which wasn't true -- Administration (System, Users, Groups, Backups, API Access, Logs & Retention, Danger Zone) is completely invisible to Viewer, the same as Standard, not merely read-only. The role summary, the Access Lists doc's Groups field, the Certificates doc's Check now section, and the Dashboard doc's Resync now section all now say plainly which actions are administrator-only.
`v0.16.21` gives API Access tokens full parity with every other tile type. Tokens now get a real, persistent custom icon -- a new `icon`/`icon_slug` column pair on the `api_tokens` table (added via an idempotent `ALTER TABLE`, safe on existing installs), matching storage functions, and a `tokens` branch in the shared icon-upload/search/URL routes -- plus the same "•••" card menu every other tile has, with Change icon and Revoke token moved into it. While wiring this up, found and fixed a real pre-existing bug: Groups' own "Change icon" menu item has been broken since it shipped, because the frontend code that actually saves an icon never mapped the `groups` kind to anything and silently fell through to the Hosted Sites endpoint, which always 404'd. Also finished the rest of the API Access fix list: the Full access/Read-only counts in the summary bar now only tally active tokens, so they stay consistent with the Active/Revoked split instead of quietly including tokens that can no longer authenticate; a "Hide revoked" toggle sits at the right of that same summary bar for anyone who's revoked enough tokens over time that the tile grid gets cluttered; and the documentation now explains why a revoked token can't be deleted outright -- the record stays for the same accountability reasons the Audit log is never editable.
`v0.16.22` fixes Docker socket detection for the common case where `/var/run/docker.sock` is correctly bind-mounted but Site Gateway still reports "not detected." Root cause: `detectDockerSocket()` checks that the running process can actually read the socket, but the container drops straight from root to the unprivileged `PUID:PGID` with no supplementary groups, and the socket is typically owned `root:docker` on the host with mode 660 -- so a perfectly correct mount still fails an unprivileged read check with no group membership behind it. `docker-entrypoint.sh` now handles this automatically: while still root, it reads the socket's actual group GID directly off the mount (no hardcoded GID -- it varies by host, Unraid, Debian, Synology, and others all differ), creates a matching local group if one doesn't already exist, adds the app user to it, and hands `su-exec` a username instead of a bare `uid:gid` so supplementary groups actually apply via `initgroups()`. Every step is best-effort and guarded: if anything about the detection or group setup fails, the container starts exactly as it always has, just without Docker integration, the same as if the socket weren't mounted at all. Also documented in the System tab's Environment & Integrations section, including the one thing this can't route around: the check runs once at boot, so a container that already has the mount added still needs an actual restart, not just a reload, to pick it up.
`v0.16.23` fixes the "Restart application" button on the System tab doing nothing at all after you confirm the restart in its popup: the dialog closes, the button text never changes to "Restarting...", no toast appears, and no restart actually happens -- explaining why the earlier restart-not-logged report showed no trace anywhere (no activity entry, no audit entry, no fresh boot sequence in the container's own console log), because the request never reached the server in the first place. Root cause: `event.currentTarget` is only valid while a DOM event is still being dispatched -- the browser resets it to `null` once dispatch finishes. The Restart handler read `event.currentTarget` *after* `await`-ing the confirmation dialog, by which point the click event had long since finished dispatching, so that line threw against a null reference before ever reaching the `/api/system/restart` call, and the error had nowhere to surface since it happened outside the handler's own try/catch. Resync and Reload were never affected because both of those capture their button reference as their very first line, before any `await`. Fixed by capturing the button reference synchronously at the top of the Restart handler too, matching the other two.
`v0.16.24` fixes the "Restart application" button never recovering after a successful restart -- following v0.16.23's fix for the button doing nothing at all, a real restart now goes through correctly (the audit log and activity feed both record it as expected), but the button itself was left stuck on "Restarting..." forever, since nothing in the success path ever reset it or reloaded the page. The handler assumed the toast alone was enough and stopped there, unlike the Danger Zone's Factory Reset flow, which already polls for the server coming back online and reloads automatically. Restart now does the same: once the restart request is accepted, it polls `/api/session` once a second for up to 30 seconds and reloads the page as soon as the dashboard answers again (with a status line explaining what it's waiting on), falling back to a reload regardless if that window elapses -- so the button, and the rest of the UI, recover on their own instead of requiring a manual page refresh.
`v0.16.25` parallelizes every sequential filesystem walk found across the app after noticing the System tab's storage numbers took a while to appear -- the same pattern turned up on the Certificates tab too, and both are fixed the same way. `directorySize()` (the System tab's disk-usage breakdown) and `walkFiles()` (the Certificates tab's search for every issued certificate file) both used to visit one file or subdirectory at a time, `await`-ing each in turn before moving to the next -- on a data directory with any real number of files, that adds up to a lot of small sequential waits. Both now fan out with `Promise.all` and let the filesystem handle everything concurrently, with no change to what they return. The System tab's five-directory breakdown (sites, backups, certificates, logs, database) is now computed in parallel too, instead of one directory at a time. While tracing the Certificates tab's load time, also found and fixed a real duplicate-work bug: the "Run certificate check" button and the downloadable support report were each independently computing the certificate inventory two to three times per request (`dashboardSnapshot()`, the route handler, and `domainReadiness()` each walked and re-parsed every certificate file separately) -- `dashboardSnapshot()` and `domainReadiness()` now both accept an already-computed inventory and reuse it instead of recomputing it, and the two independent halves of a health check (the dashboard snapshot and the domain-readiness check) now run concurrently rather than one after the other. Deliberately left alone: the code paths that read log files (`readAccessLogs`) and start hosted sites/streams on boot, since both are sequential for real reasons -- the log reader stops as soon as it has enough matching entries, so reading files in parallel would do strictly more work for no benefit, and site/stream startup order matters for safe, predictable port binding.
`v0.16.26` ships three small UI fixes found while going through the System and API Access tabs. First, the Docker socket status tile's helper text was long enough to truncate with "…" inside its `.health-tile` card -- shortened to the single fact that matters there ("Site Gateway reads the Docker socket read-only to list running containers."), dropping the network-scoping detail so it's consistent with the tile's other single-fact entries (BACKUP_PASSWORD, restart policy); the dropped detail already lives in the in-app documentation. Second, the "Pick container" button sat visibly higher than the target field beside it on the Proxy Hosts, Streaming Hosts, and Settings target fields -- root cause was the sitewide `input{margin-top:7px}` label-gap rule still applying to the input after it's wrapped in a flex row alongside the button, giving the two flex children mismatched margin boxes; the same `7px` is now applied to the wrapper instead and zeroed on the nested input, so the row centers cleanly. Third, the API Access tab's summary bar (Active/Revoked/Full access/Read-only counts plus the "Hide revoked" toggle) could render much taller than intended at certain window widths -- its stat groups and the toggle had no protection against shrinking, so at narrower widths the browser would wrap their text internally instead of just running out of room, and a flex container sizes itself to its tallest child. Added `white-space:nowrap` and `flex-shrink:0` to the summary's stat groups and the "Hide revoked" toggle so they hold their line, plus `flex-wrap` on the summary bar itself as a fallback so if the whole row genuinely doesn't fit, complete items wrap to a new line instead of any single item's text breaking mid-phrase.
`v0.16.27` fixes the real, deeper causes behind two v0.16.26 fixes that turned out to be incomplete -- both confirmed by rendering the actual markup and CSS in a headless browser and measuring the real computed heights before and after, rather than reasoning from the stylesheet alone. The API Access summary bar was still rendering 32px taller than the Users tab's summary bar (84px vs 52px, measured) even after last release's `white-space`/`flex-shrink` fix, because that fix addressed a different problem (text wrapping) than what was actually happening here: the sitewide `label{margin:var(--space-4) 0 0}` rule -- meant to space a stacked field label above its input -- was also landing on the "Hide revoked" toggle, since it's built as a `<label>` too. That gave it a lopsided 16px top margin with no bottom margin, and a flex row sizes itself to its tallest child's full margin box, so the whole bar grew to accommodate it. This is the same bug class the System tab's Docker toggle was already patched for (`.system-integrations .check-control{margin:0}`) -- the API tokens toggle just didn't get the same treatment when it was added. Fixed by zeroing that toggle's margin the same way. (A real, much smaller ~16px difference remains between the two bars, and that part is expected: the API bar contains an actual bordered, padded checkbox control, and Users' doesn't, so its row is naturally a little taller than one built from plain text alone.) Separately, the "Pick container" button was still measurably 2px shorter than its target field (41px vs 39px, measured) even after realigning their margins -- the button and input use different padding values (11px vs the shared 12px `--space-3`), and no amount of margin/alignment fiddling closes a real padding gap. Gave both an explicit `height:44px`, the same fixed control height already used throughout the app for this exact kind of row (dialog inputs, the log host selector, Settings target fields), so they're now pixel-identical rather than approximately matched.
`v0.16.28` closes the API Access summary bar's remaining height gap against every other tab's summary bar (Users, Groups, Hosted Sites, Proxy Hosts, and so on) -- measured with the same headless-browser approach as v0.16.27: 68px vs 52px before this release, now 53px vs 52px, a difference too small to see and driven only by the checkbox input's own fixed 17px size (every checkbox in the app is 17px; shrinking just this one to save the last pixel would have made it the odd one out). The remaining gap came from the "Hide revoked" toggle's bordered, padded pill styling -- a treatment none of the other tabs' summary bars use, since none of them embed a control inline with their stat counts. Rather than keep splitting the difference, the toggle now sits flush in the bar like the stat counts beside it: no border, no background, no padding, and its label text no longer inherits `.check-control`'s 1.35 line-height (meant for roomier form checkboxes, not a compact inline one). It still reads clearly as an interactive control -- the checkbox itself, its green accent color, and the pointer cursor on hover are untouched -- it just no longer sits inside its own nested box within the already-bordered summary bar.
`v0.16.29` adds a hero panel to the top of the Administration > System tab -- a single, visually distinct "one-stop shop" for this container's live CPU, memory, swap, disk, and network numbers, plus request throughput, all in one place instead of scattered across the plain status tiles below it. CPU, memory, and swap all read directly from this container's own cgroup v2 files (`cpu.stat`'s `usage_usec`, `memory.current`/`memory.max`, `memory.swap.current`/`memory.swap.max`) rather than host-level figures, on the same reasoning already settled for this feature: Site Gateway is rarely the only thing running on the host, so a host-wide number would be misleading in a dashboard scoped to one container. CPU percent is computed from two samples of the cumulative `usage_usec` counter taken a poll apart, normalized against `cpu.max`'s quota when one is set (or the host's core count when it isn't); memory and swap read straight off their `.current`/`.max` pairs, with swap showing "Off" rather than a stale percentage when the container has none configured. Disk reuses the same `statfs`-on-the-data-volume approach the System tab's storage breakdown already used. Network throughput is new: since `/sys/class/net/*/statistics/{rx_bytes,tx_bytes}` are cumulative counters too, a background sampler reads every non-loopback interface every 5 seconds and keeps a rolling rate in memory, so the hero panel always shows a real, smoothed rate rather than a lifetime total or a jittery two-reads-per-request estimate. Request throughput reuses the exact number already shown on the main Dashboard ("requests in the last minute"), so the two stay in sync without duplicating the underlying query. Each stat degrades independently and visibly rather than silently: a metric with no readable source (cgroup v1 hosts, a sandboxed `/sys/class/net`, and so on) shows a dash and a one-line explanation instead of a wrong number or a blank space, and CPU/memory/swap/disk fills turn amber past 75% and red past 90%, matching the color language already used elsewhere in the app for degraded/warning states.
`v0.16.30` fixes two numbers on the System tab's new hero panel (added in v0.16.29) that were technically correct but meant the wrong thing. CPU percent was always computed against either a real Docker `--cpus` quota or, absent one, the *host's total core count* -- so pinning the container to 2 specific cores (`--cpuset-cpus`, Unraid's CPU pinning field) didn't change the denominator at all, since pinning caps which cores can run without capping how much of them can be used, and `cpu.max` stays `max` either way. CPU now checks `cpuset.cpus.effective` (the actual pinned core list, correctly counting ranges like `0-1,4`) whenever there's no real quota, and the hero panel's detail line now says which denominator applies -- "Of N allocated CPUs" for a real `--cpus` quota, "Of N pinned cores" for cpuset pinning with no quota, or "Of host's N cores -- no limit set" when neither is configured -- instead of always claiming "Of this container's CPU quota" even when there wasn't one. Swap had a similar honesty problem: without an explicit `--memory-swap` limit, `memory.swap.max` reads `max` (unbounded, shared with the host's swap) rather than "0," but the panel showed a bare "0 B" that read like a real, enforced cap. It now only shows a percentage when a real swap limit exists; otherwise it shows the actual bytes in use with "Unlimited -- shares host swap" instead of implying a limit that was never set.
`v0.16.31` adds the two remaining items from the System tab hero panel's fix list. First, the panel now keeps itself current while you're actually looking at it: a lightweight timer polls `/api/system/health` directly every 7 seconds whenever the System tab is the visible admin panel, separate from the app's full `refresh()` (which also refetches sites, proxies, certificates, and everything else) so it stays cheap on a fast interval, and it's a no-op the moment you navigate away rather than continuing to poll in the background. Previously the hero panel only updated on initial page load or whenever *anything else* in the app happened to trigger a `refresh()` -- sitting on the tab watching it did nothing. Second, a new `DATA_DIR_LIMIT_GB` environment variable lets an operator tell the Disk stat what's actually assigned to this deployment -- a dedicated share or zvol smaller than the whole host volume, for instance -- instead of always showing usage against the full underlying filesystem size. This is necessarily display-only, since Docker has no real per-container disk-space quota the way it does for CPU (`cpu.max`) or memory (`memory.max`); actual usage and free space still come straight from `statfs` on the real volume, only the percentage's denominator and the "used of X assigned" label change. Set past 100%, the stat turns red rather than silently capping, since exceeding an assigned allowance is a real, meaningful warning rather than a display bug.
`v0.16.32` fixes a real bug in v0.16.31's `DATA_DIR_LIMIT_GB` disk allowance: the percentage it computed compared an assigned per-app allowance (e.g. 30 GB) against `statfs`'s used-space figure for the *entire filesystem* behind `/data` -- which on a shared array, cache pool, or any volume with other things living on it, has nothing to do with how much Site Gateway itself has actually written. A container assigned 30 GB sitting on a host volume that's 160 GB full of unrelated data showed as "534% used," which is a meaningless number dressed up as a warning. When `DATA_DIR_LIMIT_GB` is set, the Disk stat now compares against Site Gateway's own actual footprint instead -- the same recursive `/data` walk (`directorySize()`) the System tab's storage breakdown already performs -- so the percentage reflects what this app has actually written, not what else happens to share its disk. That walk only runs when the environment variable is actually set, since it isn't free and the whole-volume `statfs` numbers (used with no assigned limit configured) don't need it.
`v0.16.33` fixes the System tab hero panel's Network stat printing absurd, layout-breaking values like "846.7603211009175 B/s" instead of a clean rounded number. Root cause: `formatBytes()` only rounds once a value crosses into KB -- below 1024 it returns the number exactly as given, which has always been fine because every other caller passes it a file size (always a whole integer). The Network stat is the first caller to feed it a computed rate (bytes divided by elapsed seconds), which is almost never a whole number, so sub-1 KB/s readings rendered with a dozen decimal places and wrapped onto a second line, breaking the hero panel's layout. `formatRate()` now rounds to the nearest whole byte before handing off to `formatBytes()`, matching what every other value passing through it already looks like.
+43 -1
View File
@@ -29,9 +29,51 @@ fi
export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed"
export XDG_CONFIG_HOME="${DATA_DIR:-/data}/caddy/config"
# --- Docker socket group access -------------------------------------------------------------
# A bind-mounted /var/run/docker.sock is typically owned root:docker on the host with mode
# 0660 -- readable only by root or members of that group. The app drops straight to an
# unprivileged PUID:PGID with no supplementary groups, so even a correctly mounted socket looks
# "not detected" to it. The Docker group's GID varies host to host (Unraid, Debian, Synology,
# etc. all differ), so rather than hardcode one, read it directly off the mounted socket while
# still root, make sure a local group with that GID exists and the app user is a member of it,
# then hand su-exec a username instead of a bare uid:gid so it picks up supplementary groups via
# initgroups() -- the uid:gid form only ever sets the one primary group. Every step here is
# best-effort: if anything fails, app_exec_target stays the original "$app_uid:$app_gid" and the
# app starts exactly as it always has, just without Docker integration -- same as an unmounted
# socket, never worse.
app_exec_target="$app_uid:$app_gid"
docker_socket="/var/run/docker.sock"
if [ -S "$docker_socket" ]; then
docker_gid="$(stat -c '%g' "$docker_socket" 2>/dev/null || true)"
if [ -n "$docker_gid" ] && [ "$docker_gid" != "$app_gid" ]; then
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
if [ -z "$docker_group_name" ]; then
addgroup -g "$docker_gid" sgdockersock 2>/dev/null || true
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
fi
if [ -n "$docker_group_name" ]; then
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
if [ -z "$app_group_name" ]; then
addgroup -g "$app_gid" sgapp 2>/dev/null || true
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
fi
if [ -n "$app_group_name" ] && ! getent passwd "$app_uid" >/dev/null 2>&1; then
adduser -D -H -u "$app_uid" -G "$app_group_name" sgapp 2>/dev/null || true
fi
app_user_name="$(getent passwd "$app_uid" 2>/dev/null | cut -d: -f1 || true)"
if [ -n "$app_user_name" ]; then
addgroup "$app_user_name" "$docker_group_name" 2>/dev/null || true
if id -nG "$app_user_name" 2>/dev/null | grep -qw "$docker_group_name"; then
app_exec_target="$app_user_name"
fi
fi
fi
fi
fi
su-exec "$app_uid:$app_gid" caddy run --config "$caddyfile" --adapter caddyfile &
caddy_pid=$!
su-exec "$app_uid:$app_gid" "$@" &
su-exec "$app_exec_target" "$@" &
app_pid=$!
shutdown() {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.18",
"version": "0.16.33",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+11 -9
View File
@@ -228,7 +228,7 @@ function renderDashboard() {
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift"
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span><button type="button" class="button secondary" data-drift-resync>Resync now</button></div>`
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span>${canAdmin() ? '<button type="button" class="button secondary" data-drift-resync>Resync now</button>' : ""}</div>`
: `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`
).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
@@ -501,8 +501,8 @@ function render() {
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -512,7 +512,7 @@ function render() {
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, 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];
$("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? " Create group" : " 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" ? (adminGroupsActive ? " Create group" : adminApiActive ? " Create token" : " 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") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
@@ -657,6 +657,7 @@ $("#event-category").addEventListener("change", renderLogs);
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
function openCreate() {
if (state.view === "administration" && state.adminTab === "groups") { openNewGroupEditor(); return; }
if (state.view === "administration" && state.adminTab === "api") { openCreateApiTokenDialog(); return; }
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
@@ -772,12 +773,13 @@ $("#icon-search").addEventListener("input", event => {
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
}, 280);
});
async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); }
async function saveIcon(slug) {
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
$("#icon-error").textContent = "";
try {
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
$("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
$("#icon-dialog").close(); await refreshIconTargetView(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
} catch (error) { $("#icon-error").textContent = error.message; }
}
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
@@ -785,13 +787,13 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
$("#icon-upload").addEventListener("change", async event => {
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Custom icon saved locally."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
$("#save-icon-url").addEventListener("click", async () => {
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Icon URL saved."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
+118 -16
View File
@@ -269,12 +269,12 @@ function openGroupEditor(group) { let dialog = document.querySelector("#group-di
document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true);
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
document.addEventListener("click", async event => { const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } if (state.user?.role !== "administrator") { field.innerHTML = '<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration \u2192 Groups.</p>'; return; } if (!state.groups) return; const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true);
document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });
document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAccessGroupSelector(row.dataset.accessId), 10); });
function decorateAccessGroups() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item) return; if (item.groups?.length && !card.querySelector(".access-group-preview")) { const names = item.groups.map(id => state.groups.find(group => group.id === id)?.name).filter(Boolean); if (names.length) { const preview = document.createElement("p"); preview.className = "access-group-preview"; preview.textContent = "Groups: " + names.join(" · "); card.querySelector(".card-footer")?.before(preview); } } if (!card.querySelector("[data-access-action=toggle]")) { const footer = card.querySelector(".card-footer"); const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", item.enabled !== false ? "Disable Access List" : "Enable Access List"); toggle.innerHTML = "<span></span>"; footer?.querySelector(".card-actions")?.append(toggle); } }); }
function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = `<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length ? `<div class="access-group-options">${state.groups.filter(group => group.enabled !== false).map(group => `<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length || 0} members</small></span></label>`).join("")}</div>` : '<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`; guidance.after(groupField); }
function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const isAdmin = state.user?.role === "administrator"; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = isAdmin ? "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later." : "After saving, edit this Access List to assign protected hosts."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = !isAdmin ? '<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration → Groups.</p>' : `<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length ? `<div class="access-group-options">${state.groups.filter(group => group.enabled !== false).map(group => `<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length || 0} members</small></span></label>`).join("")}</div>` : '<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`; guidance.after(groupField); }
document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(renderNewAccessGuidance, 0));
document.addEventListener("click", event => { if (event.target.closest(".create-trigger") && state.view === "access") setTimeout(renderNewAccessGuidance, 0); });
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
@@ -356,18 +356,29 @@ async function loadApiTokens() {
const list = document.querySelector("#api-token-list"); if (!list) return;
const summary = document.querySelector("#api-token-summary");
try {
const tokens = await api("/api/tokens");
const tokens = state.apiTokens = await api("/api/tokens");
if (summary) {
// Full access / Read-only only count ACTIVE tokens -- a revoked token's scope no longer
// means anything operationally, so folding it into these counts would make them disagree
// with Active + Revoked, which already account for every token issued.
const counts = { active: 0, revoked: 0, full: 0, readOnly: 0 };
for (const token of tokens) { if (token.revoked) counts.revoked += 1; else counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; }
summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("");
for (const token of tokens) { if (token.revoked) { counts.revoked += 1; continue; } counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; }
summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + `<label class="check-control api-token-hide-revoked"><input type="checkbox" id="api-token-hide-revoked"${state.hideRevokedTokens ? " checked" : ""}><span>Hide revoked</span></label>`;
}
list.innerHTML = tokens.length ? tokens.map(token => {
const visibleTokens = state.hideRevokedTokens ? tokens.filter(token => !token.revoked) : tokens;
list.innerHTML = visibleTokens.length ? visibleTokens.map(token => {
const status = apiTokenStatus(token);
return `<article class="site-card api-token-card ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><div class="card-top"><div class="site-icon">TK</div></div><h2>${extendedEscape(token.name)}</h2><p class="address">${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}</p><p class="gateway-address">${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}</p><p class="gateway-address">${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status.dot}"></span>${extendedEscape(status.label)}</span><div class="card-actions">${token.revoked ? "" : '<button class="button secondary danger-text" data-token-action="revoke">Revoke</button>'}</div></div></article>`;
}).join("") : '<p class="quiet-state padded">No API tokens have been issued yet.</p>';
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="API token options" aria-expanded="false">•••</button><div class="menu"><button data-token-action="icon">Change icon</button>${token.revoked ? "" : '<button data-token-action="revoke" class="danger-text">Revoke token</button>'}</div></div>`;
return `<article class="site-card api-token-card ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><div class="card-top"><div class="site-icon">${featureIcon(token, "TK")}</div>${menu}</div><h2>${extendedEscape(token.name)}</h2><p class="address">${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}</p><p class="gateway-address">${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}</p><p class="gateway-address">${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status.dot}"></span>${extendedEscape(status.label)}</span></div></article>`;
}).join("") : `<p class="quiet-state padded">${tokens.length ? "No active tokens — uncheck \u201cHide revoked\u201d to see revoked tokens." : "No API tokens have been issued yet."}</p>`;
} catch (error) { list.innerHTML = `<p class="quiet-state padded">${extendedEscape(error.message)}</p>`; }
}
document.addEventListener("change", event => {
const checkbox = event.target.closest("#api-token-hide-revoked"); if (!checkbox) return;
state.hideRevokedTokens = checkbox.checked;
loadApiTokens();
});
window.loadApiTokens = loadApiTokens;
function renderApiTokensPanel() {
if (state.user?.role !== "administrator") return;
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
@@ -378,7 +389,7 @@ function renderApiTokensPanel() {
if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "api"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); }
if (panel.dataset.ready) return;
panel.dataset.ready = "1";
panel.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">Programmatic access</p><h2>API access tokens</h2><p class="muted">Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrators password, or disabling their account, revokes every token they issued.</p></div><div class="row-actions"><button id="create-api-token" class="button primary" type="button">Create token</button></div></div><div id="api-token-summary" class="summary"></div><div id="api-token-list" class="user-grid"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
panel.innerHTML = '<div id="api-token-summary" class="summary"></div><div id="api-token-list" class="user-grid"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
tab.addEventListener("click", async () => {
document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab));
document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel));
@@ -395,8 +406,10 @@ function showIssuedApiToken(result) {
});
dialog.showModal();
}
document.addEventListener("click", async event => {
if (!event.target.closest("#create-api-token")) return;
// Called from the shared global "+ Create" header button (see openCreate() in app.js) when the API
// Access tab is active -- there is no dedicated "Create token" button in the panel itself anymore,
// matching how Groups and Users route their create actions through the same shared button.
async function openCreateApiTokenDialog() {
const dialog = featureDialog("create-api-token-dialog");
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Create an API token</h2></div></div><p class="muted">Re-enter your administrator credentials to confirm. The token inherits your role.</p><label>Token name<input name="name" maxlength="60" required placeholder="Home Assistant integration"></label><label>Scope<select name="scope"><option value="full">Full access — read and change configuration</option><option value="read-only">Read-only — GET requests only</option></select></label><label>Expires after <span class="optional">Optional</span><input name="expiresInDays" type="number" min="1" max="3650" placeholder="Leave empty for no expiry"><small>Number of days. Leave empty for a token that never expires.</small></label><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><p class="error" id="api-token-error"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create token</button></div></form>';
dialog.showModal();
@@ -409,7 +422,22 @@ document.addEventListener("click", async event => {
await loadApiTokens();
showIssuedApiToken(result);
} catch (error) { toast(error.message, "error"); }
});
}
// Menu-open/close toggle for API token cards, matching the same pattern used for Groups' menu.
document.addEventListener("click", event => {
const button = event.target.closest("#api-token-list .api-token-card .menu-button"); if (!button) return;
const card = button.closest(".api-token-card"); const opening = !card.classList.contains("menu-open");
document.querySelectorAll("#api-token-list .api-token-card.menu-open").forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); });
card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening));
event.preventDefault(); event.stopImmediatePropagation();
}, true);
document.addEventListener("click", event => {
const button = event.target.closest('#api-token-list [data-token-action="icon"]'); if (!button) return;
const row = button.closest("[data-token-id]"); if (!row) return;
event.preventDefault(); event.stopImmediatePropagation();
row.classList.remove("menu-open");
openIconPicker("tokens", row.dataset.tokenId);
}, true);
document.addEventListener("click", async event => {
const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
const row = button.closest("[data-token-id]"); if (!row) return;
@@ -486,7 +514,7 @@ function renderDockerPanel() {
toggle.disabled = !socketMounted;
tile.querySelector(".status-dot").className = `status-dot ${socketMounted ? "running" : "idle"}`;
tile.querySelector("#docker-integration-help").textContent = socketMounted
? "Site Gateway reads the Docker socket read-only to list running containers, and only offers containers that share a Docker network with it."
? "Site Gateway reads the Docker socket read-only to list running containers."
: "Docker socket not detected — mount /var/run/docker.sock into this container to enable container selection.";
control.classList.toggle("is-disabled", !socketMounted);
}
@@ -543,6 +571,48 @@ document.addEventListener("click", async event => {
});
// Formats a byte rate as e.g. "1.2 MB/s"; reuses formatBytes and just appends the rate suffix.
// formatBytes() only rounds once a value crosses into KB -- below that it echoes the raw
// number verbatim, which is fine for the file sizes its normally fed (always whole integers)
// but not for a computed rate, so round to a whole byte first.
function formatRate(bytesPerSecond) { return `${formatBytes(Math.round(bytesPerSecond))}/s`; }
function setHeroStat(key, { value, percent, detail, tone } = {}) {
const valueEl = document.querySelector(`#system-hero-${key}-value`), fillEl = document.querySelector(`#system-hero-${key}-fill`), detailEl = document.querySelector(`#system-hero-${key}-detail`);
if (valueEl) valueEl.textContent = value ?? "\u2014";
if (fillEl) { fillEl.style.width = `${Math.max(0, Math.min(100, percent ?? 0))}%`; fillEl.className = `system-hero-fill${tone ? ` ${tone}` : ""}`; }
if (detailEl) detailEl.textContent = detail || "";
}
// Populates the System tab's hero panel (CPU/memory/swap/disk/network/throughput) from
// /api/system/health. Each stat degrades gracefully to a dash when its source isn't available
// (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host).
function renderSystemHealthHero(health) {
if (!document.querySelector("#system-hero-grid")) return;
if (!health) { ["cpu", "memory", "swap", "disk", "network", "throughput"].forEach(key => setHeroStat(key, { value: "\u2014", detail: "Unavailable" })); return; }
const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
if (health.cpu) {
const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`;
setHeroStat("cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel });
}
else setHeroStat("cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
if (health.memory) setHeroStat("memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` });
else setHeroStat("memory", { value: "\u2014", detail: "cgroup memory stats unavailable" });
// Swap only gets a real percentage when the container has an actual --memory-swap limit set
// (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap,
// so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead.
if (health.swap && health.swap.configured === false) setHeroStat("swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" });
else if (health.swap && health.swap.limitBytes) setHeroStat("swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` });
else if (health.swap) setHeroStat("swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" });
else setHeroStat("swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
if (health.disk) {
const overAssigned = health.disk.assignedLimitBytes && health.disk.percent > 100;
const diskDetail = health.disk.assignedLimitBytes ? `${formatBytes(health.disk.usedBytes)} used of ${formatBytes(health.disk.assignedLimitBytes)} assigned` : `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free`;
setHeroStat("disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: Math.min(100, health.disk.percent), tone: overAssigned ? "critical" : tone(health.disk.percent), detail: diskDetail });
}
else setHeroStat("disk", { value: "\u2014", detail: "Disk stats unavailable" });
if (health.network) setHeroStat("network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` });
else setHeroStat("network", { value: "\u2014", detail: "Sampling\u2026" });
setHeroStat("throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
}
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
function renderSystemPanel() {
if (state.user?.role !== "administrator") return;
@@ -556,6 +626,11 @@ function renderSystemPanel() {
panel.dataset.ready = "1";
panel.innerHTML = [
'<div class="panel-heading"><div><h2>System</h2><p class="muted">What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.</p></div></div>',
'<div class="system-hero"><div class="system-hero-grid" id="system-hero-grid">' +
["cpu:CPU", "memory:Memory", "swap:Swap", "disk:Disk", "network:Network", "throughput:Throughput"].map(entry => { const [key, label] = entry.split(":");
return `<div class="system-hero-stat" data-hero-stat="${key}"><span class="system-hero-label">${label}</span><strong class="system-hero-value" id="system-hero-${key}-value">\u2014</strong><div class="system-hero-bar"><div class="system-hero-fill" id="system-hero-${key}-fill"></div></div><small class="system-hero-detail" id="system-hero-${key}-detail"></small></div>`;
}).join("") +
'</div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
@@ -576,11 +651,36 @@ function renderSystemPanel() {
finally { button.disabled = false; button.textContent = original; }
});
panel.querySelector("#system-restart").addEventListener("click", async event => {
const button = event.currentTarget;
if (!await themedConfirm("Restart Site Gateway?", "The application will stop and restart. This takes a few seconds and briefly interrupts hosted sites and the dashboard.", "Restart")) return;
const button = event.currentTarget; button.disabled = true; button.textContent = "Restarting\u2026";
try { await api("/api/system/restart", { method: "POST" }); toast("Restarting \u2014 this dashboard will be unavailable briefly."); }
button.disabled = true; button.textContent = "Restarting\u2026";
const restartStatus = document.querySelector("#system-restart-status");
try {
await api("/api/system/restart", { method: "POST" });
toast("Restarting \u2014 this dashboard will be unavailable briefly.");
button.textContent = "Waiting for Site Gateway\u2026";
if (restartStatus) restartStatus.textContent = "Reconnecting once Site Gateway comes back online\u2026";
for (let attempt = 0; attempt < 30; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const response = await fetch("/api/session", { cache: "no-store" });
if (response.ok) { location.reload(); return; }
} catch { /* Still restarting -- the dashboard is briefly unreachable while the container comes back up. */ }
}
location.reload();
}
catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Restart application"; }
});
// Keep the hero panel's live numbers current while the System tab is actually visible --
// a lightweight direct poll of /api/system/health, not a full refresh() (which also
// refetches sites/proxies/certificates/etc.), so it stays cheap even on a fast interval.
// Stops itself from doing any work (skips the fetch) once the tab isn't in view, mirroring
// the guard the Dashboard's own health timer already uses for the same reason.
if (!state.systemHealthTimer) state.systemHealthTimer = setInterval(() => {
const systemPanel = document.querySelector('[data-admin-panel="system"]');
if (state.view !== "administration" || !systemPanel || systemPanel.classList.contains("hidden")) return;
api("/api/system/health").then(renderSystemHealthHero).catch(() => {});
}, 7000);
}
renderSystemStatus(panel);
}
@@ -604,11 +704,13 @@ async function renderSystemStatus(panel) {
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
if (version) version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")}<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> &middot; Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
try {
const [sec, store, policy] = await Promise.all([
const [sec, store, policy, health] = await Promise.all([
api("/api/system/security"),
api("/api/system/storage"),
api("/api/system/restart-policy"),
api("/api/system/health").catch(() => null),
]);
renderSystemHealthHero(health);
if (security) security.innerHTML = [
{ ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." },
{ ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." },
File diff suppressed because one or more lines are too long
+23 -4
View File
@@ -44,8 +44,8 @@ h2{letter-spacing:-.025em}
.wide{width:100%}
/* Top summary bar & status dot indicator */
.summary{display:flex;align-items:center;gap:28px;margin:var(--space-7) 0 28px;padding:17px 20px;background:rgba(var(--panel-rgb),.75);border:1px solid var(--line);border-radius:var(--radius-2xl)}
.summary>div{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:.88rem}
.summary{display:flex;align-items:center;flex-wrap:wrap;gap:28px;margin:var(--space-7) 0 28px;padding:17px 20px;background:rgba(var(--panel-rgb),.75);border:1px solid var(--line);border-radius:var(--radius-2xl)}
.summary>div{display:flex;align-items:center;flex-shrink:0;gap:9px;color:var(--muted);font-size:.88rem;white-space:nowrap}
.summary strong{color:var(--text)}
.port-note{margin-left:auto}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--status-dot-idle)}
@@ -950,8 +950,9 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.copy-icon{width:16px;height:16px;display:block}
/* Docker container picker */
.target-with-picker{display:flex;gap:var(--space-2);align-items:center}
.target-with-picker input{flex:1;min-width:0}
.target-with-picker{display:flex;gap:var(--space-2);align-items:center;margin-top:7px}
.target-with-picker input{flex:1;min-width:0;margin-top:0;height:44px;box-sizing:border-box}
.target-with-picker .container-picker-trigger{height:44px;box-sizing:border-box}
.container-picker-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:46vh;overflow:auto}
.container-choice{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:var(--space-3) 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.22);color:var(--text);text-align:left;cursor:pointer}
.container-choice:hover{border-color:var(--border-hover)}
@@ -963,6 +964,8 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.api-token-card{min-height:0}
.api-token-card.revoked{opacity:.6}
.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.api-token-hide-revoked{margin:0 0 0 auto;padding:0;border:0;background:transparent;font-size:.85rem;flex-shrink:0;white-space:nowrap}
.api-token-hide-revoked span{line-height:normal}
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
/* Backup history timeline */
@@ -974,3 +977,19 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.check-control.is-disabled{opacity:.55}
.check-control.is-disabled span{color:var(--muted)}
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
/* System tab hero: one-stop CPU/memory/swap/disk/network/throughput readout, styled like the
decorative .metric-card gradient tiles rather than the plain .health-tile rows below it. */
.system-hero{margin:0 0 18px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));box-shadow:0 14px 40px rgba(var(--black-rgb),.14)}
.system-hero-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:var(--space-4)}
.system-hero-stat{min-width:0;display:flex;flex-direction:column;gap:6px}
.system-hero-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:650;text-transform:uppercase;letter-spacing:.04em}
.system-hero-value{font-size:1.5rem;font-weight:800;line-height:1.1}
.system-hero-bar{height:6px;border-radius:var(--radius-full);background:rgba(var(--bg-rgb),.4);overflow:hidden}
.system-hero-fill{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease}
.system-hero-fill.warning{background:var(--warning)}
.system-hero-fill.critical{background:var(--danger)}
.system-hero-stat[data-hero-stat="network"] .system-hero-bar,.system-hero-stat[data-hero-stat="throughput"] .system-hero-bar{display:none}
.system-hero-detail{color:var(--muted);font-size:var(--font-size-xs);min-height:1.2em}
@media(max-width:1100px){.system-hero-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:420px){.system-hero-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+202 -31
View File
@@ -5,6 +5,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import dgram from "node:dgram";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -83,14 +84,145 @@ function recordActivity(message, status = "ok") {
}
async function directorySize(directory) {
let total = 0;
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
for (const entry of entries) {
const sizes = await Promise.all(entries.map(async entry => {
const itemPath = path.join(directory, entry.name);
if (entry.isDirectory()) total += await directorySize(itemPath);
else if (entry.isFile()) total += (await fsp.stat(itemPath)).size;
if (entry.isDirectory()) return directorySize(itemPath);
if (entry.isFile()) return (await fsp.stat(itemPath)).size;
return 0;
}));
return sizes.reduce((sum, size) => sum + size, 0);
}
return total;
// --- System health: cgroup v2 CPU/memory/swap sampling + network throughput -------------------
// All readings are container-scoped (cgroup v2), not host-wide, because Site Gateway usually
// isn't the only thing running on the host and host-wide numbers would be misleading in a
// per-container dashboard. Falls back to host-level approximations (with a flag the UI can use
// to disclaim them) when cgroup v2 files aren't readable -- e.g. cgroup v1 hosts, or a container
// runtime that doesn't expose them.
const CGROUP_ROOT = "/sys/fs/cgroup";
async function readCgroupFile(name) {
try { return (await fsp.readFile(path.join(CGROUP_ROOT, name), "utf8")).trim(); } catch { return null; }
}
let lastCpuSample = null; // { usageMicros, atMs } -- usage_usec is cumulative, so CPU% needs a delta between two samples.
// cpu.max sets a real CFS quota (from Docker's --cpus flag); cpuset.cpus.effective is the pinned
// core *list* (from --cpuset-cpus / Unraid's CPU pinning), which caps which cores can run but not
// how much of them can be used -- pinning alone leaves cpu.max at "max". Percent needs a real
// denominator either way, and which one applies (and thus what the number means) has to be
// reported back to the UI so the label doesn't lie about what's being measured.
async function cgroupCpuQuota() {
const max = await readCgroupFile("cpu.max");
if (max) { const [quota, period] = max.split(/\s+/); if (quota !== "max") { const q = Number(quota), p = Number(period); if (q > 0 && p > 0) return { cpus: q / p, source: "quota" }; } }
const pinned = await readCgroupFile("cpuset.cpus.effective");
if (pinned) { const count = expandCpuList(pinned); if (count > 0) return { cpus: count, source: "pinned" }; }
return { cpus: os.cpus().length || 1, source: "host" };
}
// cpuset.cpus.effective is a comma-separated list of cores and ranges, e.g. "0-1,4" -- count how
// many individual CPUs that covers rather than assuming a single contiguous range.
function expandCpuList(list) {
return list.split(",").reduce((total, part) => {
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
if (!range) return total;
const start = Number(range[1]), end = range[2] !== undefined ? Number(range[2]) : start;
return total + Math.max(0, end - start + 1);
}, 0);
}
async function cgroupCpuPercent() {
const stat = await readCgroupFile("cpu.stat");
if (!stat) return null;
const match = stat.match(/^usage_usec (\d+)/m);
if (!match) return null;
const usageMicros = Number(match[1]), atMs = Date.now();
const previous = lastCpuSample;
lastCpuSample = { usageMicros, atMs };
if (!previous) return null; // First call has nothing to diff against -- the next poll will have a real number.
const elapsedMicros = (atMs - previous.atMs) * 1000;
if (elapsedMicros <= 0) return null;
const quota = await cgroupCpuQuota();
const percent = ((usageMicros - previous.usageMicros) / elapsedMicros) / quota.cpus * 100;
return { percent: Math.max(0, Math.min(100, percent)), quotaCpus: quota.cpus, quotaSource: quota.source };
}
async function cgroupMemory() {
const current = await readCgroupFile("memory.current");
if (current === null) return null;
const maxRaw = await readCgroupFile("memory.max");
const totalBytes = os.totalmem();
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : totalBytes;
const usedBytes = Number(current);
return { usedBytes, limitBytes, percent: limitBytes > 0 ? (usedBytes / limitBytes) * 100 : null };
}
async function cgroupSwap() {
const current = await readCgroupFile("memory.swap.current");
if (current === null) return null;
const maxRaw = await readCgroupFile("memory.swap.max");
const usedBytes = Number(current);
if (maxRaw === "0") return { usedBytes: 0, limitBytes: 0, percent: null, configured: false };
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : null;
return { usedBytes, limitBytes, percent: limitBytes ? (usedBytes / limitBytes) * 100 : null, configured: true };
}
// Network counters are cumulative since the interface came up, so throughput needs a delta
// between two samples too -- sampled on a fixed interval in the background (rather than on
// each request) so the rate stays smooth regardless of how often the dashboard polls.
let lastNetworkSample = null; // { rxBytes, txBytes, atMs }
let networkRate = null; // { rxBytesPerSec, txBytesPerSec }
async function sampleNetworkInterfaces() {
try {
const names = (await fsp.readdir("/sys/class/net")).filter(name => name !== "lo");
const totals = await Promise.all(names.map(async name => {
const [rx, tx] = await Promise.all([
fsp.readFile(`/sys/class/net/${name}/statistics/rx_bytes`, "utf8").catch(() => "0"),
fsp.readFile(`/sys/class/net/${name}/statistics/tx_bytes`, "utf8").catch(() => "0"),
]);
return { rx: Number(rx.trim()) || 0, tx: Number(tx.trim()) || 0 };
}));
const rxBytes = totals.reduce((sum, value) => sum + value.rx, 0), txBytes = totals.reduce((sum, value) => sum + value.tx, 0), atMs = Date.now();
if (lastNetworkSample) {
const elapsedSeconds = (atMs - lastNetworkSample.atMs) / 1000;
if (elapsedSeconds > 0) networkRate = { rxBytesPerSec: Math.max(0, (rxBytes - lastNetworkSample.rxBytes) / elapsedSeconds), txBytesPerSec: Math.max(0, (txBytes - lastNetworkSample.txBytes) / elapsedSeconds) };
}
lastNetworkSample = { rxBytes, txBytes, atMs };
} catch { /* No readable network interfaces (e.g. host networking with restricted /sys) -- the hero panel just omits the network stat. */ }
}
setInterval(sampleNetworkInterfaces, 5000).unref();
sampleNetworkInterfaces();
// One combined snapshot for the System tab's hero panel -- CPU/memory/swap/network are all
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
// statfs-on-the-data-volume approach as /api/system/storage.
async function systemHealthSnapshot() {
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
const assignedLimitBytes = assignedLimitGb && assignedLimitGb > 0 ? assignedLimitGb * 1024 ** 3 : null;
const [cpu, memory, swap, disk, appUsedBytes] = await Promise.all([
cgroupCpuPercent(),
cgroupMemory(),
cgroupSwap(),
fsp.statfs(dataDir).catch(() => null),
// Only walk /data (the same directorySize() the storage breakdown below already uses) when
// an assigned limit is actually configured -- it's the one case that needs it, and the walk
// isn't free, so skip it when the panel is just going to show whole-volume stats anyway.
assignedLimitBytes !== null ? directorySize(dataDir) : Promise.resolve(null),
]);
return {
cpu,
memory,
swap,
disk: disk ? (() => {
const totalBytes = disk.blocks * disk.bsize, freeBytes = disk.bfree * disk.bsize, availableBytes = disk.bavail * disk.bsize, volumeUsedBytes = totalBytes - freeBytes;
// DATA_DIR_LIMIT_GB lets an operator tell the hero panel what's actually assigned to this
// deployment (e.g. a dedicated share/zvol sized smaller than the whole host volume), since
// Docker has no real per-container disk-space quota to read the way it does for CPU/memory.
// Purely a display denominator -- it doesn't enforce anything -- so usage over 100% is a
// real, meaningful warning rather than a bug: it means actual usage has exceeded what was assigned.
// Critically, comparing against an assigned allowance has to use Site Gateway's own actual
// footprint (appUsedBytes, a real walk of /data), not the whole filesystem's used space --
// statfs reports usage for the entire volume behind /data, which on a shared array or pool
// includes everything else living on that mount, not just what this app has written.
const usedBytes = assignedLimitBytes !== null ? appUsedBytes : volumeUsedBytes;
const denominatorBytes = assignedLimitBytes || totalBytes;
return { totalBytes, freeBytes, availableBytes, usedBytes, assignedLimitBytes, percent: (usedBytes / denominatorBytes) * 100 };
})() : null,
network: networkRate,
throughput: { liveRequests: storage.performanceLiveCount(60) },
};
}
function numberEnv(name, fallback) {
@@ -729,13 +861,14 @@ function publicStream(stream) {
// --- Certificate inventory & domain readiness diagnostics --------------------------------------
async function walkFiles(directory) {
const output = [];
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
const results = await Promise.all(entries.map(entry => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) output.push(...await walkFiles(fullPath));
else if (entry.isFile()) output.push(fullPath);
}
return output;
if (entry.isDirectory()) return walkFiles(fullPath);
if (entry.isFile()) return [fullPath];
return [];
}));
return results.flat();
}
function certificateNames(certificate) {
@@ -748,15 +881,15 @@ async function certificateInventory() {
const configured = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))]
.filter(item => item.enabled && item.domain && item.tls !== "http");
const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const parsed = [];
const certificateFiles = [...await walkFiles(certificateDir), ...await walkFiles(customCertificatesDir)];
for (const filename of certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file))) {
const [managedCertificateFiles, customCertificateFiles] = await Promise.all([walkFiles(certificateDir), walkFiles(customCertificatesDir)]);
const certificateFiles = [...managedCertificateFiles, ...customCertificateFiles];
const parsed = (await Promise.all(certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file)).map(async filename => {
try {
const certificate = new crypto.X509Certificate(await fsp.readFile(filename));
const stat = await fsp.stat(filename);
parsed.push({ certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" });
} catch { /* Ignore non-certificate PEM files and unreadable entries. */ }
}
const [contents, stat] = await Promise.all([fsp.readFile(filename), fsp.stat(filename)]);
const certificate = new crypto.X509Certificate(contents);
return { certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" };
} catch { return null; /* Ignore non-certificate PEM files and unreadable entries. */ }
}))).filter(Boolean);
const certificates = configuredDomains.map(item => {
const found = parsed.find(entry => entry.names.some(name => name === item.domain || (name.startsWith("*.") && item.domain.endsWith(name.slice(1)))));
if (!found) {
@@ -793,10 +926,9 @@ async function pruneOrphanedCertificates(candidateDomains) {
}
async function domainReadiness() {
async function domainReadiness(precomputedCertificates) {
const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const certs = await certificateInventory();
const [httpResponding, httpsResponding] = await Promise.all([tcpProbe(80), tcpProbe(443)]);
const [certs, httpResponding, httpsResponding] = await Promise.all([precomputedCertificates ? Promise.resolve(precomputedCertificates) : certificateInventory(), tcpProbe(80), tcpProbe(443)]);
return Promise.all(routes.map(async item => {
let addresses = [], dnsError = null;
try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; }
@@ -954,12 +1086,12 @@ async function cacheIcon(slug) {
// --- Dashboard snapshot: aggregates health/status across every subsystem for the
// Overview page and the /api/dashboard endpoint --------------------------------------------
async function dashboardSnapshot() {
async function dashboardSnapshot(precomputedCertificates) {
const hosted = sites.map(publicSite);
const proxyHosts = proxies.map(publicProxy);
const enabledStreams = streams.filter(item => item.enabled !== false);
const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length };
const certificates = await certificateInventory();
const certificates = precomputedCertificates || await certificateInventory();
const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length;
const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([
fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false),
@@ -1581,13 +1713,15 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
app.get("/api/system/health", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
});
app.get("/api/system/storage", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
const breakdown = {};
for (const [key, dir] of Object.entries({ sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") })) {
breakdown[key] = await directorySize(dir);
}
const breakdownDirs = { sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") };
const breakdown = Object.fromEntries(await Promise.all(Object.entries(breakdownDirs).map(async ([key, dir]) => [key, await directorySize(dir)])));
let capacity = null;
try {
const stats = await fsp.statfs(dataDir);
@@ -1654,7 +1788,7 @@ app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json
app.get("/api/tokens", (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, icon: token.icon, iconSlug: token.iconSlug, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
} catch (error) { next(error); }
});
app.post("/api/tokens", async (req, res, next) => {
@@ -1783,7 +1917,12 @@ app.get("/api/certificates", async (req, res, next) => {
catch (error) { next(error); }
});
app.post("/api/health/check", async (req, res, next) => {
try { await checkAllProxies(); res.json({ dashboard: await dashboardSnapshot(), certificates: await certificateInventory(), readiness: await domainReadiness() }); }
try {
await checkAllProxies();
const certificates = await certificateInventory();
const [dashboard, readiness] = await Promise.all([dashboardSnapshot(certificates), domainReadiness(certificates)]);
res.json({ dashboard, certificates, readiness });
}
catch (error) { next(error); }
});
app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } });
@@ -1795,8 +1934,9 @@ app.get("/api/support-report", async (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const certificateReport = await certificateInventory();
const readiness = await domainReadiness(certificateReport);
certificateReport.latestError = certificateReport.latestError ? { present:true, at:certificateReport.latestError.at } : null;
const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness: await domainReadiness(), recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) };
const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness, recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) };
res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2));
} catch (error) { next(error); }
});
@@ -1879,6 +2019,24 @@ function entryLabel(item) {
}
app.put("/api/:kind/:id/icon", async (req, res, next) => {
try {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
let updated;
if (req.body.url !== undefined) {
const url = String(req.body.url || "").trim();
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
updated = storage.setApiTokenIcon(req.params.id, { icon: url, iconSlug: null });
recordActivity(`Icon URL updated for “${entryLabel(existing)}”.`);
} else {
const slug = String(req.body.slug || "").trim();
const icon = slug ? await cacheIcon(slug) : null;
updated = storage.setApiTokenIcon(req.params.id, { icon, iconSlug: slug || null });
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(existing)}”.`);
}
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id);
@@ -1902,6 +2060,19 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
});
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
try {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
if (!req.file) return res.status(400).json({ error: "Choose an icon image." });
if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(req.file.mimetype)) return res.status(400).json({ error: "Use PNG, JPEG, WebP, GIF, or SVG." });
const extension = req.file.mimetype === "image/svg+xml" ? "svg" : req.file.mimetype.split("/")[1].replace("jpeg", "jpg");
const filename = `${req.params.kind}-${existing.id}.${extension}`;
await fsp.rename(req.file.path, path.join(iconsDir, filename));
const updated = storage.setApiTokenIcon(req.params.id, { icon: `/site-icons/${filename}`, iconSlug: null });
recordActivity(`Custom icon uploaded for “${entryLabel(existing)}”.`);
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id);
+5 -2
View File
@@ -75,6 +75,8 @@ export async function openStorage(dataDir, backupsDir) {
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
`);
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon TEXT"); } catch { /* Column already exists. */ }
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon_slug TEXT"); } catch { /* Column already exists. */ }
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 schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
@@ -178,11 +180,12 @@ export async function openStorage(dataDir, backupsDir) {
}
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
// every authenticated API request looks a token up by its SHA-256 hash.
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,icon,icon_slug AS iconSlug,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
function setApiTokenIcon(id, { icon, iconSlug }, instanceId = LOCAL_INSTANCE_ID) { const changes = db.prepare("UPDATE api_tokens SET icon=?, icon_slug=? WHERE instance_id=? AND id=?").run(icon || null, iconSlug || null, instanceId, id).changes; return changes > 0 ? listApiTokens(instanceId).find(item => item.id === id) || null : null; }
// --- Backup history. Independent of what is on disk, so deleted backups and failed
// attempts stay visible in the timeline.
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
@@ -212,5 +215,5 @@ export async function openStorage(dataDir, backupsDir) {
}
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() };
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, setApiTokenIcon, recordBackupEvent, listBackupEvents, close: () => db.close() };
}