fix: daemon /run spawn hardening, auth guard before first paint, WS refresh cap, interfaces runtime state
systemd: pre-create volatile /run paths so vacuum-walld's ProtectSystem=strict namespace setup cannot fail with 226/NAMESPACE — RuntimeDirectory=vacuum-wall nginx plus a tmpfiles.d spec (installed to /etc/tmpfiles.d/) covering /run/firewalld and /run/nginx.pid. Drop /run/sudo from ReadWritePaths: NOPASSWD children never need it, and its absence crash-looped restarts after sudo removed /run/sudo.
webui: run the auth session check before mounting the shell so logged-out visitors never flash the sidebar or a protected page; router guard and sidebar now react to auth state, and the login page renders full-bleed.
ws: cap refresh->reconnect episodes at 2 consecutive failures; if the WS path stays dead after a token refresh, abandon reconnection instead of looping refreshAuth forever (UI keeps working via REST until reload).
api: GET /api/network/interfaces now includes loopback and returns per-interface {config, runtime}; dashboard reads runtime.state (carrier counts as up) and the interfaces page filters lo client-side.
daemon: re-collect nginx state after lazy config migration (cached list went stale when the on-disk format changed under it), skip system_import.nginx when config.json already exists (re-parsing vacuum-wall's own generated sites is lossy), and poll nginx (60s) / acme (300s) state so file drift self-heals.
This commit is contained in:
@@ -127,7 +127,7 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
runtime = parse_networkctl_status(raw)
|
runtime = parse_networkctl_status(raw)
|
||||||
|
|
||||||
merged: dict[str, Any] = {}
|
merged: dict[str, Any] = {}
|
||||||
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
|
all_names = set(ifaces_cfg.keys()) | set(runtime.keys())
|
||||||
for name in sorted(all_names):
|
for name in sorted(all_names):
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
"config": ifaces_cfg.get(name, {}),
|
"config": ifaces_cfg.get(name, {}),
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ def _get_config() -> dict[str, Any]:
|
|||||||
cfg, changed = _migrate_config(raw)
|
cfg, changed = _migrate_config(raw)
|
||||||
if changed:
|
if changed:
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
|
# The on-disk config format changed under the state store's feet;
|
||||||
|
# re-collect so cached state (e.g. the domains list) matches the file.
|
||||||
|
refresh_state(["nginx"])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +175,9 @@ def _get_backends() -> dict[str, Any]:
|
|||||||
backends["webui"]["_migrated"] = True
|
backends["webui"]["_migrated"] = True
|
||||||
cfg["backends"] = backends
|
cfg["backends"] = backends
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
|
# Config file was rewritten (builtin backend materialized); re-collect
|
||||||
|
# so cached state matches the file.
|
||||||
|
refresh_state(["nginx"])
|
||||||
return backends
|
return backends
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1780,7 +1780,7 @@ Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface co
|
|||||||
GET /api/network/interfaces
|
GET /api/network/interfaces
|
||||||
```
|
```
|
||||||
|
|
||||||
Return all configured interfaces with their network config and runtime state from `networkctl`.
|
Return all network interfaces (configured and live, including loopback) with their network config and runtime state from `networkctl`. `runtime.state` is the networkctl operational state (`routable`, `degraded`, `carrier`, `off`, …).
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ Both `config/` and `data/` reside within the project directory. The systemd serv
|
|||||||
|
|
||||||
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
|
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
|
||||||
|
|
||||||
|
`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends).
|
||||||
|
|
||||||
## File System Layout
|
## File System Layout
|
||||||
|
|
||||||
The following file system locations are used for integration with system services:
|
The following file system locations are used for integration with system services:
|
||||||
@@ -317,6 +319,9 @@ The following file system locations are used for integration with system service
|
|||||||
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
|
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
|
||||||
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
||||||
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
|
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
|
||||||
|
| `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) |
|
||||||
|
| `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) |
|
||||||
|
| `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) |
|
||||||
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
|
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
|
||||||
|
|
||||||
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
||||||
|
|||||||
+3
-1
@@ -87,8 +87,10 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
│ ├── systemd/ # Service and timer unit files
|
│ ├── systemd/ # Service and timer unit files
|
||||||
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
|
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
|
||||||
│ │ └── vacuum-wall-acme.timer # Renewal schedule
|
│ │ ├── vacuum-wall-acme.timer # Renewal schedule
|
||||||
|
│ │ └── vacuum-walld.service # Privileged daemon (rendered at install)
|
||||||
│ ├── sudoers.d/ # Sudo whitelist (rendered at install)
|
│ ├── sudoers.d/ # Sudo whitelist (rendered at install)
|
||||||
|
│ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim)
|
||||||
│ ├── nginx/ # Nginx config templates (rendered at runtime)
|
│ ├── nginx/ # Nginx config templates (rendered at runtime)
|
||||||
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
|
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
|
||||||
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
|
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
|
||||||
|
|||||||
+3
-2
@@ -145,8 +145,9 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
|
|||||||
| Directive | Value | Effect |
|
| Directive | Value | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||||||
| `ReadWritePaths` | project dir, `/tmp`, `/run/vacuum-wall`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
|
| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`); (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it |
|
||||||
| `RuntimeDirectory` | `vacuum-wall` (daemon only) | Creates `/run/vacuum-wall` owned by the daemon user; removed on stop |
|
| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop |
|
||||||
|
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself) |
|
||||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||||
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
|
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
|||||||
"dnsmasq": 10,
|
"dnsmasq": 10,
|
||||||
"networkd": 10,
|
"networkd": 10,
|
||||||
"system": 30,
|
"system": 30,
|
||||||
|
# nginx/acme state derives from config files (and lazy in-place migration
|
||||||
|
# can rewrite them without a mutation); poll so drift self-heals.
|
||||||
|
"nginx": 60,
|
||||||
|
"acme": 300,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -713,7 +713,20 @@ def _parse_bool(val: str) -> bool | str:
|
|||||||
|
|
||||||
|
|
||||||
def import_nginx() -> bool:
|
def import_nginx() -> bool:
|
||||||
"""Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json."""
|
"""Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json.
|
||||||
|
|
||||||
|
Bootstraps config.json on hosts that already have rendered sites
|
||||||
|
(repo reinstalled over an existing data/ dir). If the declarative
|
||||||
|
config already exists it wins: sites are vacuum-wall's own generated
|
||||||
|
output ("do not edit manually") and re-parsing them is lossy — backend
|
||||||
|
references get flattened to inline paths, which render empty nginx
|
||||||
|
sites and hide domains from the WebUI.
|
||||||
|
"""
|
||||||
|
cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json"
|
||||||
|
if cfg_path.exists():
|
||||||
|
logger.debug("Skipping nginx: %s already exists", cfg_path)
|
||||||
|
return False
|
||||||
|
|
||||||
if not NGINX_SITES_DIR.exists():
|
if not NGINX_SITES_DIR.exists():
|
||||||
logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR)
|
logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR)
|
||||||
return False
|
return False
|
||||||
@@ -741,7 +754,6 @@ def import_nginx() -> bool:
|
|||||||
logger.debug("Skipping nginx: no valid site files")
|
logger.debug("Skipping nginx: no valid site files")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json"
|
|
||||||
existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}})
|
existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}})
|
||||||
domains_cfg = existing.setdefault("domains", {})
|
domains_cfg = existing.setdefault("domains", {})
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,11 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
|
|||||||
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service
|
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service
|
||||||
|
|
||||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
||||||
|
|
||||||
|
# Volatile /run entries (sudo, firewalld) must exist before vacuum-walld
|
||||||
|
# spawns — systemd-tmpfiles-setup.service restores them at every boot.
|
||||||
|
install -m 0644 "${PROJECT_DIR}/system/tmpfiles.d/vacuum-wall.conf" /etc/tmpfiles.d/vacuum-wall.conf
|
||||||
|
systemd-tmpfiles --create
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
# --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) ---
|
# --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) ---
|
||||||
|
|||||||
@@ -18,15 +18,39 @@ Environment=PYTHONUNBUFFERED=1
|
|||||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
Environment=HOME={{ PROJECT_DIR }}
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
|
|
||||||
# Runtime directory for temp files used during config apply
|
# Runtime directories created before namespace setup. ProtectSystem=strict
|
||||||
RuntimeDirectory=vacuum-wall
|
# makes the whole hierarchy read-only, and namespace setup fails
|
||||||
|
# (exit 226/NAMESPACE) if any ReadWritePaths= entry is missing at spawn.
|
||||||
|
# /run is a fresh tmpfs at every boot, so volatile /run paths must be
|
||||||
|
# created up front (RuntimeDirectory= here; /run/firewalld via
|
||||||
|
# system/tmpfiles.d/vacuum-wall.conf and by firewalld itself) rather than
|
||||||
|
# at first use.
|
||||||
|
# vacuum-wall : secure temp files used during config apply
|
||||||
|
# nginx : /run/nginx (listed in ReadWritePaths)
|
||||||
|
RuntimeDirectory=vacuum-wall nginx
|
||||||
RuntimeDirectoryMode=0750
|
RuntimeDirectoryMode=0750
|
||||||
|
|
||||||
LogsDirectory=vacuum-wall
|
LogsDirectory=vacuum-wall
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall
|
# NOTE: every ReadWritePaths= entry must exist when the unit spawns or namespace
|
||||||
|
# setup fails (226/NAMESPACE). Volatile /run entries are pre-created:
|
||||||
|
# /run/vacuum-wall, /run/nginx → RuntimeDirectory= (above)
|
||||||
|
# /run/firewalld → system/tmpfiles.d/vacuum-wall.conf (and is
|
||||||
|
# present while firewalld runs, which starts
|
||||||
|
# before this unit)
|
||||||
|
# /run/sudo is intentionally NOT listed: the daemon's sudo children use the
|
||||||
|
# NOPASSWD whitelist and never need sudo's session directory (verified with
|
||||||
|
# the directory absent). Listing it made the unit crash-loop whenever it
|
||||||
|
# restarted after the last sudo session had ended and sudo removed /run/sudo.
|
||||||
|
# /run/nginx.pid IS listed: nginx -t opens the pid file for *writing* in
|
||||||
|
# addition to -s/acme reading it, so a read-only mount makes every daemon-side
|
||||||
|
# `nginx -t` (and therefore /nginx/apply) fail with EROFS. The file is
|
||||||
|
# pre-created by system/tmpfiles.d/vacuum-wall.conf so the ReadWritePaths=
|
||||||
|
# entry always exists at spawn (nginx rewrites it on start; nginx -t does
|
||||||
|
# not modify its contents).
|
||||||
|
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Volatile /run entries that must exist before vacuum-walld spawns.
|
||||||
|
#
|
||||||
|
# vacuum-walld runs with ProtectSystem=strict and lists these paths in
|
||||||
|
# ReadWritePaths=; if a ReadWritePaths= entry is missing at spawn time,
|
||||||
|
# systemd's mount-namespace setup fails (exit 226/NAMESPACE) and the unit
|
||||||
|
# crash-loops without ever creating data/daemon.sock. /run is a fresh tmpfs
|
||||||
|
# at every boot, so every /run path the unit references needs a boot-time
|
||||||
|
# creator. Status per path:
|
||||||
|
#
|
||||||
|
# /run/vacuum-wall, /run/nginx -> unit RuntimeDirectory= (daemon-owned)
|
||||||
|
# /run/firewalld -> this file (the firewalld unit only creates
|
||||||
|
# it while firewalld itself is running)
|
||||||
|
# /run/nginx.pid -> this file (nginx rewrites it on start; the
|
||||||
|
# daemon's `nginx -t` must be able to open
|
||||||
|
# it for writing inside its ProtectSystem=strict
|
||||||
|
# namespace, so it needs both a boot-time
|
||||||
|
# creator and a ReadWritePaths= entry)
|
||||||
|
# /run/sudo -> not referenced by the unit (see
|
||||||
|
# ReadWritePaths note in vacuum-walld.service);
|
||||||
|
# the sudo package ships its own tmpfiles spec
|
||||||
|
#
|
||||||
|
# Applied at early boot by systemd-tmpfiles-setup.service and by the install
|
||||||
|
# script (`systemd-tmpfiles --create`) for existing hosts.
|
||||||
|
d /run/firewalld 0750 root root -
|
||||||
|
f /run/nginx.pid 0644 root root -
|
||||||
+49
-19
@@ -219,21 +219,45 @@ const Pages = {
|
|||||||
/* ── Router ────────────────────────────────────────────────── */
|
/* ── Router ────────────────────────────────────────────────── */
|
||||||
const router = {
|
const router = {
|
||||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||||
isAuthenticated: false,
|
|
||||||
component() {
|
component() {
|
||||||
const name = this.state.path.replace(/^\//, '');
|
const { path } = this.state;
|
||||||
|
// Auth guard: unauthenticated users see the login page for any
|
||||||
|
// protected route (manual hash entry, back/forward, runtime
|
||||||
|
// expiry). Reactive — the auth model's data mutation re-renders
|
||||||
|
// this function, so the real page appears the instant login
|
||||||
|
// completes.
|
||||||
|
if (path !== '/login' && !isAuthenticated()) {
|
||||||
|
return hComp(LoginPage, '/login');
|
||||||
|
}
|
||||||
|
const name = path.replace(/^\//, '');
|
||||||
const page = Pages[name] || NotFoundPage;
|
const page = Pages[name] || NotFoundPage;
|
||||||
return hComp(page, this.state.path);
|
return hComp(page, path);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set once the bootstrap session check settles (and implicitly on every
|
||||||
|
// later login/logout transition — isAuthenticated flips reactively). Until
|
||||||
|
// then the hashchange clamp below must NOT force unauthenticated hashes to
|
||||||
|
// #/login: a valid-session reload arrives with its 'check' still in flight,
|
||||||
|
// and clamping early would strand the user on login.
|
||||||
|
let authChecked = false;
|
||||||
|
|
||||||
window.location.hash || (window.location.hash = router.state.path);
|
window.location.hash || (window.location.hash = router.state.path);
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
const raw = location.hash.slice(1) || '/dashboard';
|
||||||
|
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
|
||||||
|
router.state.path = path;
|
||||||
|
// Keep the URL in sync with the clamped path (loop-safe: the follow-up
|
||||||
|
// hashchange lands on the already-clamped '/login').
|
||||||
|
if (location.hash.slice(1) !== path) location.hash = path;
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Sidebar render root ───────────────────────────────────── */
|
/* ── Sidebar render root ───────────────────────────────────── */
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
|
// No nav when logged out — unauthenticated users get the full-bleed
|
||||||
|
// login page. Reactive: the auth model's terminal transition (logout /
|
||||||
|
// session expiry) re-renders this root back to null.
|
||||||
|
if (!isAuthenticated()) return null;
|
||||||
const current = router.state.path;
|
const current = router.state.path;
|
||||||
const nav = getNav();
|
const nav = getNav();
|
||||||
return h('div', { class: 'sidebar' },
|
return h('div', { class: 'sidebar' },
|
||||||
@@ -260,16 +284,8 @@ function MainContent() {
|
|||||||
|
|
||||||
/* ── Init ──────────────────────────────────────────────────── */
|
/* ── Init ──────────────────────────────────────────────────── */
|
||||||
export async function initApp() {
|
export async function initApp() {
|
||||||
const sidebarEl = document.getElementById('sidebar');
|
|
||||||
const mainEl = document.getElementById('main');
|
|
||||||
if (sidebarEl && mainEl) {
|
|
||||||
render(sidebarEl, Sidebar);
|
|
||||||
render(mainEl, MainContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for login events to update router state after auth
|
// Listen for login events to update router state after auth
|
||||||
window.addEventListener('auth:login', () => {
|
window.addEventListener('auth:login', () => {
|
||||||
router.isAuthenticated = true;
|
|
||||||
// Defer to a macrotask: at dispatch time (microtask) the login form's
|
// Defer to a macrotask: at dispatch time (microtask) the login form's
|
||||||
// hash change has not run yet — router.state.path is still '/login'.
|
// hash change has not run yet — router.state.path is still '/login'.
|
||||||
// The deferred check runs after the hashchange task, so a fresh login
|
// The deferred check runs after the hashchange task, so a fresh login
|
||||||
@@ -290,17 +306,31 @@ export async function initApp() {
|
|||||||
disconnect();
|
disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check auth state before connecting WS
|
// Check auth state BEFORE mounting the shell: an unauthenticated visitor
|
||||||
|
// must never flash the sidebar or a protected page before the redirect
|
||||||
|
// to #/login lands.
|
||||||
await modelFetch('auth', { action: 'check' });
|
await modelFetch('auth', { action: 'check' });
|
||||||
|
authChecked = true;
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
router.isAuthenticated = true;
|
// A session restored at bootstrap (or a reload) may leave the URL on
|
||||||
|
// #/login — the auth guard renders the login form for that hash even
|
||||||
|
// when authenticated. Bounce to the default page so a valid session
|
||||||
|
// never strands the user on a stale login screen.
|
||||||
|
if (router.state.path === '/login') {
|
||||||
|
window.location.hash = '/dashboard';
|
||||||
|
}
|
||||||
fetchInitialData();
|
fetchInitialData();
|
||||||
setTimeout(connect, 0);
|
setTimeout(connect, 0);
|
||||||
} else {
|
} else if (router.state.path !== '/login') {
|
||||||
// No valid session — redirect to login
|
// No valid session — redirect to login before the first paint.
|
||||||
if (router.state.path !== '/login') {
|
window.location.hash = '/login';
|
||||||
window.location.hash = '/login';
|
}
|
||||||
}
|
|
||||||
|
const sidebarEl = document.getElementById('sidebar');
|
||||||
|
const mainEl = document.getElementById('main');
|
||||||
|
if (sidebarEl && mainEl) {
|
||||||
|
render(sidebarEl, Sidebar);
|
||||||
|
render(mainEl, MainContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
* never on the refresh promise. Terminal (no-token) transitions are
|
* never on the refresh promise. Terminal (no-token) transitions are
|
||||||
* handled by the auth model's onSuccess (clears storage, redirects,
|
* handled by the auth model's onSuccess (clears storage, redirects,
|
||||||
* dispatches auth:logout).
|
* dispatches auth:logout).
|
||||||
|
*
|
||||||
|
* The refresh path is capped at 2 consecutive failing episodes (3 closed
|
||||||
|
* connections each): if refresh + reconnect still cannot establish a
|
||||||
|
* socket, the WS path itself is dead, and retrying would loop token
|
||||||
|
* rotation forever. Reconnection is then abandoned until the page is
|
||||||
|
* reloaded; the UI keeps working via the REST API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { refreshByTopic } from './model.js';
|
import { refreshByTopic } from './model.js';
|
||||||
@@ -19,6 +25,11 @@ import { refreshAuth, getAuthToken } from './auth_model.js';
|
|||||||
let _wsConn = null;
|
let _wsConn = null;
|
||||||
let _wsReconnectMs = 0;
|
let _wsReconnectMs = 0;
|
||||||
let _wsFailCount = 0;
|
let _wsFailCount = 0;
|
||||||
|
// Consecutive refresh→reconnect episodes that still failed. Capped so a
|
||||||
|
// dead WS path cannot loop `refreshAuth()` forever (each 200 refresh rotates
|
||||||
|
// the user's token pair, so an unbounded loop storms the refresh endpoint).
|
||||||
|
let _wsRefreshStreak = 0;
|
||||||
|
let _wsGivingUp = false;
|
||||||
let _wsClosingHandled = false;
|
let _wsClosingHandled = false;
|
||||||
|
|
||||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
||||||
@@ -55,6 +66,8 @@ function _wsConnect() {
|
|||||||
_wsConn.onopen = () => {
|
_wsConn.onopen = () => {
|
||||||
_wsReconnectMs = 0;
|
_wsReconnectMs = 0;
|
||||||
_wsFailCount = 0;
|
_wsFailCount = 0;
|
||||||
|
_wsRefreshStreak = 0;
|
||||||
|
_wsGivingUp = false;
|
||||||
_wsClosingHandled = false;
|
_wsClosingHandled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,11 +75,25 @@ function _wsConnect() {
|
|||||||
if (_wsClosingHandled) return;
|
if (_wsClosingHandled) return;
|
||||||
_wsClosingHandled = true;
|
_wsClosingHandled = true;
|
||||||
if (!getAuthToken()) return;
|
if (!getAuthToken()) return;
|
||||||
|
if (_wsGivingUp) return;
|
||||||
_wsFailCount++;
|
_wsFailCount++;
|
||||||
|
|
||||||
if (_wsFailCount >= 3) {
|
if (_wsFailCount >= 3) {
|
||||||
const oldConn = _wsConn;
|
const oldConn = _wsConn;
|
||||||
_wsFailCount = 0;
|
_wsFailCount = 0;
|
||||||
|
_wsRefreshStreak++;
|
||||||
|
if (_wsRefreshStreak >= 2) {
|
||||||
|
// Refresh + reconnect has failed twice in a row — the WS path
|
||||||
|
// is dead (not just the token). Stop retrying: the page keeps
|
||||||
|
// working API-only, and a fresh page load (or the next
|
||||||
|
// successful socket) restarts the cycle.
|
||||||
|
_wsGivingUp = true;
|
||||||
|
console.error(
|
||||||
|
'[WS] giving up after repeated refresh+reconnect failures; ' +
|
||||||
|
'live updates paused until the page is reloaded',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await refreshAuth(); // never rejects; failure path handled by model onSuccess
|
await refreshAuth(); // never rejects; failure path handled by model onSuccess
|
||||||
if (getAuthToken()) {
|
if (getAuthToken()) {
|
||||||
_wsReconnectMs = 0;
|
_wsReconnectMs = 0;
|
||||||
|
|||||||
@@ -53,10 +53,13 @@ export default definePage({
|
|||||||
const ifaces = allNames.map(name => {
|
const ifaces = allNames.map(name => {
|
||||||
const fw = fwIfaces.find(f => f.name === name);
|
const fw = fwIfaces.find(f => f.name === name);
|
||||||
const netEntry = netIfaces[name] || {};
|
const netEntry = netIfaces[name] || {};
|
||||||
|
// /api/network/interfaces returns {config, runtime} per interface —
|
||||||
|
// state fields (state, addresses, mac) live under runtime.
|
||||||
|
const runtime = netEntry.runtime || {};
|
||||||
const traffic = sysTraffic[name] || {};
|
const traffic = sysTraffic[name] || {};
|
||||||
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
||||||
const addrs = netEntry?.addresses || [];
|
const addrs = runtime.addresses || [];
|
||||||
const isUp = ['routable', 'degraded'].some(s => (netEntry.state || '').startsWith(s));
|
const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s));
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
mac: fw?.mac || null,
|
mac: fw?.mac || null,
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export default definePage({
|
|||||||
const zones = fwZones.available || [];
|
const zones = fwZones.available || [];
|
||||||
const activeZones = fwZones.active || {};
|
const activeZones = fwZones.active || {};
|
||||||
|
|
||||||
const ifaces = Object.entries(netData).map(([name, entry]) => {
|
// Loopback has no networkd config to manage — show real NICs only.
|
||||||
|
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
|
||||||
let zone = null;
|
let zone = null;
|
||||||
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
|
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
|
||||||
if ((zIfaces || []).includes(name)) {
|
if ((zIfaces || []).includes(name)) {
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-bleed main when the sidebar renders nothing (logged-out / login view) */
|
||||||
|
#sidebar:empty ~ .main {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -255,6 +260,68 @@ body {
|
|||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
min-height: 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login,
|
||||||
|
.btn-passkey {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Badges */
|
/* Badges */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in New Issue
Block a user