From 332d14e37d3a08d5bf1ed202d7e0142d35925770 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 20 Aug 2026 01:38:00 +0000 Subject: [PATCH] ws: migrate push stream to data streaming - daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback --- AGENTS.md | 19 +- daemon/handlers/firewall.py | 82 ++- daemon/server.py | 121 +++-- docs/api.md | 56 +- docs/architecture.md | 21 +- docs/config.md | 4 + docs/deployment.md | 4 +- docs/hoover.md | 245 +++++---- docs/overview.md | 2 +- docs/security.md | 2 +- docs/state-model.md | 174 +++++++ lib/schema.py | 485 ++++++++++++++++++ lib/state.py | 42 +- tests/test-model-set.js | 111 ++++ tests/test-reconnect-fallback.js | 175 +++++++ tests/test-ws-handler.js | 220 ++++++++ tests/test_api.py | 57 ++ .../test_broadcast_versions_per_subsystem.py | 100 ++++ tests/test_firewall.py | 148 +++++- tests/test_polling.py | 26 +- tests/test_refresh_state_broadcast.py | 144 ++++++ tests/test_schema_types.py | 147 ++++++ tests/test_state.py | 25 + tests/test_ws_delta.py | 114 ++++ tests/test_ws_snapshot.py | 127 +++++ webui/api/status.py | 26 +- webui/static/app.js | 167 ++---- webui/static/hoover/api.js | 20 +- .../static/hoover/components/applyconfirm.js | 21 +- webui/static/hoover/components/data.js | 58 +-- webui/static/hoover/components/modal.js | 6 +- webui/static/hoover/index.js | 4 +- webui/static/hoover/model.js | 17 + webui/static/hoover/schema.js | 80 +++ webui/static/hoover/websocket.js | 96 ++-- webui/static/pages/backends.js | 8 +- webui/static/pages/certs.js | 11 +- webui/static/pages/dashboard.js | 40 +- webui/static/pages/dhcp.js | 21 +- webui/static/pages/interfaces.js | 21 +- webui/static/pages/nat.js | 11 +- webui/static/pages/proxy.js | 7 +- webui/static/pages/rules.js | 8 +- webui/static/pages/wireguard.js | 21 +- webui/static/pages/zones.js | 21 +- 45 files changed, 2819 insertions(+), 496 deletions(-) create mode 100644 docs/state-model.md create mode 100644 lib/schema.py create mode 100644 tests/test-model-set.js create mode 100644 tests/test-reconnect-fallback.js create mode 100644 tests/test-ws-handler.js create mode 100644 tests/test_broadcast_versions_per_subsystem.py create mode 100644 tests/test_refresh_state_broadcast.py create mode 100644 tests/test_schema_types.py create mode 100644 tests/test_ws_delta.py create mode 100644 tests/test_ws_snapshot.py create mode 100644 webui/static/hoover/schema.js diff --git a/AGENTS.md b/AGENTS.md index 69fd303..dc18870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Deploys on Debian 13 (trixie). Serves from repo root by default. ## Architecture ``` -Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090) +Client ──→ nginx (TLS; basic auth on basic-authed proxy domains only) ──→ Flask (127.0.0.1:9090) Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock) vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ system service ``` @@ -16,6 +16,15 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ syste Blueprints are thin proxies — they never call `lib/` directly. All operations flow through the daemon client over a Unix socket. +**Authentication:** the management UI gets **no** nginx-level `auth_basic` — the mgmt +server block's `location /` is a bare proxy and `/ws` is `auth_basic off`. Management +auth is the Flask-layer JWT middleware (`POST /api/auth/login` → +`Authorization: Bearer `; public paths: static files, `/vendor/`, auth endpoints) +plus the daemon WS handshake (raw JWT as the `Sec-WebSocket-Protocol` subprotocol). +Basic auth (`.htpasswd`) renders **only** for proxy domains whose +`config/nginx/config.json` has an `auth` block — never for the management domain +(see `docs/security.md`, "Management Interface"). + ### Two-User Model with Shared Group - **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`. @@ -31,7 +40,7 @@ through the daemon client over a Unix socket. - `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`. - `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls. - `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here. -- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast. +- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s). - `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`. - `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`. - `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls. @@ -59,7 +68,7 @@ Conventions: ### Daemon Endpoints - Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`) -- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state notifications +- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state streaming: full `snapshot` on connect, then per-subsystem data-carrying `versions` (structural) / `tick` (volatile-only) deltas. The client patches reactive models in place via `modelSet()` — no HTTP round-trip for auto-refresh. - Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`) - Start as `python -m daemon.server` or via the `vacuum-walld` console script @@ -119,7 +128,9 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han ## Deploy `scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars. -`MGMT_PASS` is strictly required; `MGMT_DOMAIN` auto-detected from hostname. +`MGMT_PASS` is strictly required — it is the **SQLite DB** password for the initial `admin` +user (full `rw` on all subsystems; default username `admin`), **not** an nginx htpasswd. +`MGMT_DOMAIN` auto-detected from hostname. ### Service Start Order diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index a637ebd..48c16bc 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -33,7 +33,7 @@ from daemon.iface import ( POST_FIREWALL_ZONES_INTERFACES, POST_FIREWALL_ZONES_SERVICES, ) -from daemon.server import NotFoundError, refresh_state, registry +from daemon.server import ConflictError, NotFoundError, refresh_state, registry from lib.common import load_json, run, save_json from lib.firewall import ( _normalize_target, @@ -85,6 +85,35 @@ def _reload() -> None: run(["firewall-cmd", "--reload"], sudo=True) +def _default_zone() -> str: + """Return the firewalld default zone name. + + The default zone is the catch-all for any interface without an explicit + zone assignment (including VPN interfaces), so it normally fronts the WAN. + """ + return run(["firewall-cmd", "--get-default-zone"], sudo=True).strip() + + +def _would_remove_mgmt(zone: str, services: list[str]) -> bool: + """Return True if *services* lacks both https and ssh on the default zone. + + The default zone fronts unassigned (WAN/VPN) interfaces, so removing both + management access (https via nginx) and remote recovery (ssh) from it + would leave no path back except a physical console. + """ + if "https" in services or "ssh" in services: + return False + try: + default = _default_zone() + except Exception: + logger.warning( + "Could not determine the firewalld default zone; failing closed for %s", + zone, + ) + return True + return zone == default + + def _fp_to_str(fp: dict[str, Any]) -> str: """Convert a forward-port dict to firewall-cmd CLI argument string.""" parts = [f"port={fp['port']}", f"proto={fp['proto']}"] @@ -106,18 +135,39 @@ def _get_forward_ports(zone_name: str) -> list[str]: return [] -def _config_apply() -> dict[str, Any]: +def _config_apply(force: bool = False) -> dict[str, Any]: """Apply saved declarative config to live firewalld. For each zone in the config, reconciles interfaces, services, target, masquerade, rich rules, and forward ports by removing old values first, then adding desired values. Reloads firewalld at the end. + + With *force* False (default), a ``ConflictError`` is raised before any + mutation if the config would strip both https and ssh from the default + zone; pass ``force=True`` to override. """ from lib.firewall import get_config as _get_lib_config cfg = _get_lib_config() cfg_zones = cfg.get("zones", {}) + if not force: + default_zone = _default_zone() + lockout_zones = [ + zn + for zn, zc in cfg_zones.items() + if zn == default_zone + and "https" not in zc.get("services", []) + and "ssh" not in zc.get("services", []) + ] + if lockout_zones: + raise ConflictError( + f"Refusing to remove both https and ssh from default zone(s) " + f"{', '.join(repr(z) for z in lockout_zones)}: management access " + f"and remote recovery would be lost. Add at least one of them " + f'to the zone\'s services, or pass {{"force": true}}.' + ) + full_state: dict[str, Any] = { "active_zones": {}, "interfaces": [], @@ -556,13 +606,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]: Args: _request: The incoming HTTP request (unused). - _body: The request body (unused). + _body: Optional JSON body; ``{"force": true}`` overrides the + management-lockout guard for the default zone. Returns: Dict with ``applied_zones`` (list of zone names), ``backup`` (path), and ``synced`` (affected subsystems). """ - result = _config_apply() + force = bool(_body and _body.get("force")) + result = _config_apply(force=force) logger.info("Firewall config applied: %s", result.get("applied_zones", [])) sync_result = bus.emit( SyncEvent("firewall", "config_saved", {"action": "config_applied"}) @@ -736,18 +788,21 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, @registry.register(POST_FIREWALL_ZONES_SERVICES) def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: - """Replace zone services, emitting sync event and refreshing state. + """Replace zone services, persist to declarative config, and refresh state. Args: _request: The incoming HTTP request (unused). - body: JSON body with ``zone`` and ``services`` list. + body: JSON body with ``zone`` and ``services`` list; optional + ``force`` (bool) overrides the management-lockout guard. Returns: Dict with ``zone`` and ``services`` keys. Raises: ValueError: If body is missing ``zone``. - NotFoundError: If the zone does not exist. + NotFoundError: If the specified zone does not exist. + ConflictError: If the change would strip both https and ssh from the + firewalld default zone and ``force`` is not set. """ if not body: raise ValueError("Request body required") @@ -757,6 +812,12 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A raise ValueError("'zone' is required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") + if not body.get("force") and _would_remove_mgmt(zone, list(services)): + raise ConflictError( + f"Refusing to remove both https and ssh from default zone '{zone}': " + f"management access and remote recovery would be lost. Add at least " + f'one of them back, or send "force": true to override.' + ) current = _parse_zone_output( zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) ).get("services", []) @@ -782,6 +843,13 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A sudo=True, ) _reload() + + # Keep the declarative config in sync so the next apply does not + # reconcile the live services back to the stale config value. + cfg = _get_config() + cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services) + _save_config(cfg) + logger.info("Zone '%s' services set to %s", zone, services) sync_result = bus.emit( SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone}) ) diff --git a/daemon/server.py b/daemon/server.py index 0645361..9454902 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -160,7 +160,14 @@ def refresh_state(subsystems: list[str] | None = None) -> None: except RuntimeError: pass else: - task = asyncio.create_task(broadcast_versions()) + # Broadcast each refreshed subsystem individually (gather for parallelism) + async def _broadcast_all(): + await asyncio.gather( + *[broadcast_versions(name) for name in targets], + return_exceptions=True, + ) + + task = asyncio.create_task(_broadcast_all()) task.add_done_callback(_ws_tasks.discard) _ws_tasks.add(task) @@ -401,10 +408,12 @@ _last_blacklist_cleanup_lock: asyncio.Lock | None = None async def _handle_ws(request: web.Request) -> web.Response: - """WebSocket endpoint for real-time state change notifications. + """WebSocket endpoint for real-time state streaming. - On connect: sends current versions. On state change: broadcasts - updated subsystem versions. Clients disconnect to unsubscribe. + On connect: sends a full state snapshot of every subsystem + ({type: snapshot, data: {subsystem: state, …}}). On state change: + broadcasts a data-carrying per-subsystem delta (versions or tick). + Clients disconnect to unsubscribe. Authentication: JWT access token passed via: 1. WebSocket subprotocol header — the bundled client sends the raw JWT @@ -452,7 +461,8 @@ async def _handle_ws(request: web.Request) -> web.Response: await ws.prepare(request) _ws_subscribers.add(ws) - await ws.send_json({"type": "init", "versions": state_store.get_versions()}) + snapshot = state_store.get_snapshot() + await ws.send_json({"type": "snapshot", "data": snapshot}) try: async for msg in ws: @@ -466,35 +476,59 @@ async def _handle_ws(request: web.Request) -> web.Response: return ws -async def broadcast_versions() -> None: - """Broadcast updated subsystem versions to all WebSocket clients.""" - updated = state_store.get_updated_versions() - if not updated or not _ws_subscribers: +async def _send_all(message: str) -> None: + """Send a message string to all WS subscribers, removing dead ones.""" + dead: set[web.WebSocketResponse] = set() + for ws in _ws_subscribers: + try: + await ws.send_str(message) + except Exception: + dead.add(ws) + _ws_subscribers.difference_update(dead) + if dead: + logger.warning("Removed %d dead WS subscribers", len(dead)) + + +async def broadcast_versions(subsystem: str) -> None: + """Send {type: versions} + full subsystem data to WS clients. + + NOTE: Does NOT call state_store.bump(). Callers who need version bumps + (e.g. refresh_state) call bump themselves. poll_loop bumps before calling. + No legacy `updated` field is emitted (no backward compat) — version + counters still advance but are not sent over the wire. + """ + data = state_store.get(subsystem) + if data is None: + # Collector failed during the triggering populate/refresh — populate() + # cleared this subsystem to None (state.py). Skip the broadcast: + # a null payload would overwrite good client data. The next successful + # poll or mutation broadcasts the real value. return - data = json.dumps({"type": "versions", "updated": updated}) - dead: set[web.WebSocketResponse] = set() - for ws in _ws_subscribers: - try: - await ws.send_str(data) - except Exception: - dead.add(ws) - _ws_subscribers.difference_update(dead) - if dead: - logger.warning("Removed %d dead WS subscribers", len(dead)) + message = json.dumps( + { + "type": "versions", + "subsystem": subsystem, + "data": data, + } + ) + await _send_all(message) -async def broadcast_tick(subsystems: list[str]) -> None: - """Broadcast a lightweight tick to WS clients without version payload.""" - data = json.dumps({"type": "tick", "subsystems": subsystems}) - dead: set[web.WebSocketResponse] = set() - for ws in _ws_subscribers: - try: - await ws.send_str(data) - except Exception: - dead.add(ws) - _ws_subscribers.difference_update(dead) - if dead: - logger.warning("Removed %d dead WS subscribers", len(dead)) +async def broadcast_tick(subsystem: str) -> None: + """Send {type: tick} with the changed subsystem data. + + No bump — tick is volatile-only; the version counter only bumps on + structural changes. + """ + data = state_store.get(subsystem) + message = json.dumps( + { + "type": "tick", + "subsystem": subsystem, + "data": data, + } + ) + await _send_all(message) async def _poll_loop(subsystem: str, interval: int) -> None: @@ -512,9 +546,9 @@ async def _poll_loop(subsystem: str, interval: int) -> None: structural, volatile = state_store.poll(subsystem) if structural: state_store.bump(subsystem) - await broadcast_versions() + await broadcast_versions(subsystem) # now per-subsystem, carries data elif volatile: - await broadcast_tick([subsystem]) + await broadcast_tick(subsystem) # now per-subsystem, carries data # Periodic blacklist cleanup — coordinated across all poll loops async with _last_blacklist_cleanup_lock: now = time.time() @@ -565,11 +599,24 @@ async def refresh_status(_request: web.Request) -> web.Response: body = await _request.json() except (json.JSONDecodeError, ValueError): body = None - subsystems = None - if body and "subsystems" in body: - subsystems = body["subsystems"] + subsystems = body.get("subsystems") if body else None state_store.populate(subsystems) - return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS}) + targets = subsystems or state_store.SUBSYSTEMS + snapshot = {name: state_store.get(name) for name in targets} + + # Broadcast to all WS clients (fire-and-forget, gather for parallelism). + # Deliberately no version bump — versions advance on structural poll + # diffs and on refresh_state() only. + async def _broadcast_all(): + await asyncio.gather( + *[broadcast_versions(name) for name in targets], + return_exceptions=True, + ) + + task = asyncio.create_task(_broadcast_all()) + task.add_done_callback(_ws_tasks.discard) + _ws_tasks.add(task) + return ok(snapshot) async def _catch_all(request: web.Request) -> web.Response: diff --git a/docs/api.md b/docs/api.md index 758bb54..7462b7c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1947,6 +1947,40 @@ Apply pending changes for all subsystems in dependency order. | `applied` | `[string, ...]` | List of subsystems that were applied | | `errors` | `[object, ...]` | Any errors encountered during apply | +--- + +#### Refresh State + +``` +POST /api/status/refresh +``` + +Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's `POST /status/refresh`, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a `versions` WS delta for each so all connected viewers stay in sync. + +**Request Body** (optional — `{}` or omitted refreshes all subsystems): + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystems` | `[string, ...]` | No | Subsystem names to refresh (e.g., `["firewall"]`) | + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| — | `object` | Map of the requested subsystem name(s) to its full state dict (`null` = collector not populated / failed) | + +**Example:** + +```json +// Request +{"subsystems": ["firewall"]} + +// Response +{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}} +``` + +Returns HTTP `500` if the daemon is unreachable. + ### Sysctl #### Set Kernel Parameter @@ -2069,14 +2103,22 @@ Returns HTTP `404` if the log file does not exist. ## WebSocket Protocol -The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends: +The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (`modelSet`) with no HTTP round-trip. -```json -{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}} -``` +### Handshake Authentication + +The JWT **access** token travels as the **raw `Sec-WebSocket-Protocol` subprotocol name** (the bundled client sends the bare token, no `Bearer ` prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy `Bearer ` subprotocol (non-browser clients) and an `X-Auth-Token` header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP `401` and no socket is opened. ### Message Types -- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch. -- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch. -- **`notify`** — Single-topic notification. `topic` is the subsystem name. \ No newline at end of file +| Type | Sent | Fields | Meaning | +|------|------|--------|---------| +| `snapshot` | On connect (after auth) | `data: {subsystem: state\|null, …}` | Full state for every subsystem. `null` = collector not populated / failed — clients skip those entries. | +| `versions` | Structural change | `subsystem`, `data` | The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. | +| `tick` | Volatile-only change | `subsystem`, `data` | The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. | + +There is no legacy `updated` dict or `subsystems` array — each data-carrying message names a single `subsystem` and carries its full `data`. + +### Manual Refresh + +`POST /api/status/refresh` re-collects state (optionally filtered by a `subsystems` array) and broadcasts a `versions` delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the [Status API — Refresh State](#refresh-state) section for the full request/response contract. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 5d5eaf1..f0d6848 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,19 +148,22 @@ The daemon runs background polling tasks for subsystems with external runtime st | wireguard | 10s | Peer connections/handshakes change frequently | | dnsmasq | 10s | Lease file + service status | | networkd | 10s | Interface up/down, DHCP address changes | +| system | 1s | Real-time metrics (load/memory/swap/traffic) | +| nginx | 60s | Config-file drift self-heal (lazy in-place migration) | +| acme | 300s | Config-file drift self-heal (lazy in-place migration) | -nginx, acme, and auth are not polled — they have no external runtime state. +Only `auth` is not polled — it has no external runtime state. **Two-layer diff:** Each poll cycle classifies changes as: -- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load -- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch +- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", "subsystem": ..., "data": ...}` → daemon pushes the full subsystem data over WS; the client patches the model in place via `modelSet` +- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump - **No change**: silence -Volatile fields per subsystem: `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`. +Volatile fields per subsystem: `system` (load/memory/swap/traffic), `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`. Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`). -On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`. +On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`, and `broadcast_versions` additionally skips a `None` payload defensively (a null payload would clobber good client data — the next successful poll or mutation broadcasts the real value). ## System Config Import @@ -338,11 +341,11 @@ Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 wi Authenticated ──→ mounts #sidebar and #main render roots apiFetch() ──→ injects Authorization: Bearer header ──→ Flask REST API Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions -Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091?token=) +Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer ` subprotocol + X-Auth-Token header fallbacks accepted) Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens -WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM +WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes ``` The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/` route serves vendored JS libraries. @@ -355,9 +358,9 @@ Each route is a `definePage()` component with reactive state, async data loading All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching. -### WebSocket Broadcast +### WebSocket Data Streaming -The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data. +The daemon pushes state over the WebSocket — no HTTP round-trip for auto-refresh. On connect, after the JWT handshake, it sends a full snapshot (`{"type": "snapshot", "data": {subsystem: state|null, …}}`). On every structural change it broadcasts a per-subsystem delta (`{"type": "versions", "subsystem": …, "data": …}`); on volatile-only changes it sends `{"type": "tick", "subsystem": …, "data": …}`. The client's `handleMessage` patches the matching reactive model in place via `modelSet()`, and the VDOM diff touches only the changed nodes. HTTP remains the fallback for the initial load (3s timer) and for reconnect recovery. ## Zone Model diff --git a/docs/config.md b/docs/config.md index 4135cdb..452f25b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -534,6 +534,10 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. +Both `/api/firewall/zones//services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift. + +**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones//services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed. + ## Networkd (IP Configuration) **File**: `config/network/config.json` diff --git a/docs/deployment.md b/docs/deployment.md index a7e6add..6ce97cb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -176,8 +176,8 @@ Log in with the username and password you provided during installation. 1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration 2. Confirm `data/auth.db` exists with admin user present -3. Nginx config no longer has `auth_basic` for management domain -4. WebSocket location no longer has `auth_basic off` +3. Confirm the management server block has no server-level `auth_basic` directive — the management UI is authenticated by the Flask-layer JWT middleware, not nginx +4. Confirm `location /ws` has `auth_basic off` — the WebSocket is authenticated by the daemon via the raw-JWT `Sec-WebSocket-Protocol` subprotocol, never by nginx 5. Access the WebUI at `https://` — should show a login page ### Certificate Note diff --git a/docs/hoover.md b/docs/hoover.md index b0274d1..88541ab 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -11,9 +11,9 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p | Render | `render.js` | Render engine: container-level diffing, component lifecycle | | Component | `component.js` | Page definitions, lifecycle hooks, state caching | | Router | `router.js` | Hash-based SPA router, `Link` navigation component | -| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states | + | Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states | | Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions | -| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh, `disconnect()` (terminal-auth socket teardown) | +| WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) | | API | `api.js` | JSON fetch wrapper, toast notifications, form submissions | | Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing | | Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts | @@ -26,12 +26,12 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp ``` index.html — static shell with #sidebar, #main, #modal-root └── app.js — SPA bootstrap - ├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... }) - ├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... }) - ├── modelFetch('firewall') / modelFetch('dnsmasq') / ... - ├── render(sidebarEl, Sidebar) — sidebar render root - ├── render(mainEl, MainContent) — main content render root - └── connect() — WebSocket lifecycle + ├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... }) + ├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... }) + ├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches + ├── render(sidebarEl, Sidebar) — sidebar render root + ├── render(mainEl, MainContent) — main content render root + └── connect() — WebSocket lifecycle (snapshot → modelSet) ``` The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. @@ -41,14 +41,16 @@ Each render root registers a render function via `render(container, fn)`. When r ### Data Flow ``` -WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch() - → reactivity proxy triggers render - → page.render(state) reads model data +WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data + (snapshot on connect, versions/tick deltas per subsystem) +HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch() ``` The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. -Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh. +State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`). + +Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`). ## Bootstrap @@ -58,21 +60,52 @@ The app starts from `webui/static/app.js`: import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js'; -// 1. Register subsystem models -modelRegister('firewall', { - subsystem: 'firewall', - fetch: async () => { - const r = await apiFetch('/api/firewall/config'); - if (!r.ok) throw new Error(r.error); - return r.data; - }, -}); +// 1. Register subsystem models. All state-backed models share the same +// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the +// primary data path is the WS snapshot + deltas (modelSet). +const STATE_MODELS = [ + { name: 'firewall', subsystem: 'firewall' }, + { name: 'dnsmasq', subsystem: 'dnsmasq' }, + { name: 'nginx', subsystem: 'nginx' }, + { name: 'acme', subsystem: 'acme' }, + { name: 'wireguard', subsystem: 'wireguard' }, + { name: 'network', subsystem: 'networkd' }, + { name: 'system', subsystem: 'system' }, +]; +for (const { name, subsystem } of STATE_MODELS) { + modelRegister(name, { + subsystem, + defaultData: SUBSYSTEMS[subsystem].defaults, + fetch: async () => { + const r = await apiFetch('/api/status/refresh', { + method: 'POST', + body: { subsystems: [subsystem] }, + }); + if (!r.ok) throw new Error(r.error); + const payload = r.data?.[subsystem]; + if (payload == null) throw new Error(subsystem + ': state not populated yet'); + return payload; + }, + }); +} +modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } }); +modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } }); +```javascript // ... more modelRegister calls ... -// 2. Initial fetch for all models -for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) { - modelFetch(name); +// 2. Initial data. State-backed models receive their first data via the WS +// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived. +// Non-state models fetch immediately. +function fetchInitialData() { + for (const { name } of STATE_MODELS) { + setTimeout(() => { + const model = getModel(name); + if (model.loading) modelFetch(name); // snapshot not yet delivered + }, 3000); + } + modelFetch('backends'); + modelFetch('logs', 'journal'); } // 3. Create reactive router state @@ -130,33 +163,39 @@ Manually schedule a re-render. Only one microtask is queued regardless of how ma ## Model -The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles fetching, WS invalidation, loading states, and in-flight dedup. +The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup. ### `modelRegister(name, definition)` Register a subsystem model at app bootstrap. ```javascript +// State-backed model — the fetch below is the HTTP *fallback* (POST +// /api/status/refresh with a subsystem filter); the primary path is the WS +// snapshot + per-subsystem deltas applied via modelSet(). modelRegister('firewall', { - subsystem: 'firewall', // WS topic to listen for ('*' = all) - fetch: async (signal) => { // async fetch function - const r = await apiFetch('/api/firewall/config', { signal }); + subsystem: 'firewall', // daemon subsystem ('*' = all) + defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data + fetch: async (signal) => { // HTTP fallback + const r = await apiFetch('/api/status/refresh', { + method: 'POST', + body: { subsystems: ['firewall'] }, + }); if (!r.ok) throw new Error(r.error); - return r.data; + return r.data?.firewall; // null → throw so stale data is kept }, - defaultData: null, // optional, initial data value // onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null) // onFailure: (name, error) => { }, // optional — after model.error is set (real throws only) }); -// Parameterized example — tab-aware fetch: +// Parameterized example — tab-aware fetch (non-state model): modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { const url = LOG_TABS[tab || 'journal']; const r = await apiFetch(url, { signal }); if (!r.ok) throw new Error(r.error); - return (r.data || '').split('\n').filter(l => l.length > 0); + return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' }; }, }); ``` @@ -164,7 +203,7 @@ modelRegister('logs', { | Parameter | Description | |---|---| | `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) | -| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. | +| `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) | | `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). | | `definition.defaultData` | Optional initial data value (default: `null`) | | `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. | @@ -172,7 +211,7 @@ modelRegister('logs', { ### `getModel(name)` -Get a reactive model by name. Returns the model object with `{ data, loading, refresh, error }` properties. Call in `init()` to access model state in `render()`. +Get a reactive model by name. Throws if not registered. Returns the model object with `{ data, loading, refreshing, error }` properties. Call in `init()` to access model state in `render()`. ```javascript // In page init @@ -197,18 +236,24 @@ render(state) { Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. ```javascript -// Initial load +// HTTP fallback for a state-backed model (WS snapshot is the primary path; +// app.js kicks in with modelFetch(name) if no snapshot arrives within 3s) modelFetch('firewall'); -// Post-mutation refresh -const r = await apiFetch('/api/firewall/zones', { method: 'POST', body }); -if (r.ok) modelFetch('firewall'); - -// Parameterized fetch (e.g., tab-aware logs) +// Non-state models fetch directly (not backed by the daemon state store) +modelFetch('backends'); modelFetch('logs', 'journal'); modelFetch('logs', 'nginx-access'); ``` +> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`, +> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem +> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the +> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and +> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is +> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a +> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`. + **Behavior:** - If a fetch is already in progress for this model (and param), returns the existing promise (dedup). - Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches. @@ -219,9 +264,32 @@ modelFetch('logs', 'nginx-access'); - Does not abort in-progress fetches — other consumers may still need the data. - The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key. +### `modelSet(name, data)` + +Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no +`fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a +re-render. Clears `model.loading` unconditionally on arrival of real data and resets +`model.error` to `null`. + +```javascript +// Called by websocket.js for every WS snapshot / delta — usually you will not call this +modelSet('firewall', payload); // payload: the subsystem state object +``` + +| Parameter | Description | +|---|---| +| `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. | +| `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. | + +`websocket.js` maps subsystem → model name (`networkd` → `network`), and never applies a +`null` payload (a failed collector keeps the current data). See **WS Message Types** / +**WS Data Streaming Flow** below. + ### `refreshByTopic(topic)` -Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives. +Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for +manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via +`modelSet` instead). | Model `subsystem` | Topic | Match? | |---|---|---| @@ -363,7 +431,7 @@ html`
```javascript html`<${Badge} text=${val} variant="info" />` -html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />` +html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />` ``` **Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text: @@ -489,7 +557,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`. 1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key. 2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM. -3. **WS auto-refresh**: Topic message arrives → `refreshByTopic()` → `modelFetch()` for matching models → `model.data` update → reactivity triggers `render()`. +3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`. 4. **Unmount**: `onUnmount()` called if defined → component entry destroyed. ### `hComp(renderer, key)` @@ -562,37 +630,31 @@ socket must be closed explicitly on a terminal transition; `app.js` listens for ### WS Message Types -| Type | Fields | Effect | -|---|---|---| -| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics | -| `refresh` | `topics: [topic, …]` | Same as `versions` | -| `notify` | `topic` | Refresh all models matching the topic | -| `status` | `topic` | Refresh all models matching the topic | +The daemon streams state data directly — no HTTP round-trip for auto-refresh: -Model `subsystem: '*'` matches all topics. +| Type | Fields | When sent | Effect | +|---|---|---|---| +| `snapshot` | `data: {subsystem: state \| null, …}` | Once on connect (after JWT handshake) | `modelSet()` for every subsystem; `null` payloads (failed collectors) are skipped | +| `versions` | `subsystem`, `data` | Structural change (config mutated, bump detected) | `modelSet()` for the matching model | +| `tick` | `subsystem`, `data` | Volatile-only change (e.g., `system` metrics at 1s cadence) | `modelSet()` for the matching model | -### WS Auto-Refresh Flow +Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`, +`status`) are ignored — no backward compat. -When a WS message arrives for a topic: -1. `refreshByTopic(topic)` iterates registered models. -2. Matching models call `modelFetch(name)`. -3. Model fetch updates `model.data`, triggering reactivity and page re-renders. -4. In-flight dedup prevents duplicate fetches. +System name → model name mapping is handled internally (`networkd` → `network`); unknown +subsystem names fall through to the raw name. -Pages have no awareness of WS events. The model layer handles all WS-driven refresh. +### WS Data Streaming Flow -### `onMessage(topics, handler)` +When a data-carrying WS message arrives: +1. `handleMessage()` maps the subsystem to its model name. +2. `modelSet(name, data)` replaces `model.data` in place — no fetch, no `loading`/`refreshing` churn. +3. Reactivity detects the change and re-renders the pages reading that model. +4. A `null` payload is never applied — it means the collector failed and stale good data is kept. -Direct one-off subscription for code outside `definePage`: - -```javascript -const unsub = onMessage(['firewall'], (msg) => { - // handle raw message -}); -// Later: unsub(); -``` - -Handler receives the parsed WS message object. +Pages have no awareness of WS events. Initial load uses `modelFetch` over HTTP (a 3-second timer +in `app.js` kicks in if no snapshot has arrived yet); afterwards the WS stream is the sole +auto-refresh path for state-backed models. ## API @@ -633,7 +695,7 @@ function MainContent() { ### `apiSubmit(config)` -Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success. After success, refreshes the named model(s). +Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts (appending an auto-synced note when the response includes a `synced` array), and closes the modal on success. Affected state-backed models update from the daemon's WS delta — no explicit `modelFetch`. ```javascript apiSubmit({ @@ -642,7 +704,6 @@ apiSubmit({ body: () => ({ name: $val('zone-name') }), validate: (b) => !b.name ? 'Name required' : null, successMsg: 'Zone created', - refresh: 'firewall', // model name(s) to refresh after success closeModal: () => closeModal(), // optional, called after success toast }), ``` @@ -658,10 +719,13 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp | `body` | `() => body` function, or `undefined` for no body | | `validate` | `(body) => string | null` — validation function | | `successMsg` | Success toast message | -| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success | | `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) | | `submitText` | Submit button text (default: `'Submit'`) | +> The legacy `refresh` option is no longer supported — state-backed models are +> updated by the WS delta after the mutation. To refresh a non-state model after +> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component. + ### `checkAbort(ac)` **Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management. @@ -725,7 +789,7 @@ poll({ onErrorKey: (d) => d.status === 'failed', onComplete: (d) => { toast('Certificate issued', 'success'); - modelFetch('acme'); + // No modelFetch — the WS delta updates the acme model (state-backed). }, onError: (d) => { toast('Issuance failed', 'error'); @@ -824,7 +888,7 @@ Flex button container with 8px gap. Accepts VNode children directly. ```javascript ActionGroup( h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'), - ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }), + ActionButton({ url: '/api/apply', label: 'Apply' }), ) ``` @@ -889,15 +953,16 @@ Card container with optional header. #### `ConfirmDelete(props)` -Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. Shows a spinner animation during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion until the model refresh removes it from the DOM. +Delete button with native `confirm()` dialog, then API `DELETE` call and a success toast (appending an auto-synced note when the response includes a `synced` array). Shows a spinner during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion. State-backed models update from the daemon's WS delta — no `modelFetch`. ```javascript ConfirmDelete({ url: '/api/firewall/zones/myzone', message: 'Delete zone myzone?', success: 'Zone deleted', - refresh: 'firewall', label: 'Delete', + deleteKey: 'myzone', + onComplete: () => { /* optional, runs after successful delete */ }, }) ``` @@ -908,14 +973,15 @@ ConfirmDelete({ | `url` | API DELETE URL | | `message` | Confirmation prompt text | | `success` | Success toast message (default: `'Removed'`) | -| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` | +| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. | | `label` | Button text (default: `'Remove'`) | | `body` | Optional JSON body to send with DELETE | -| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success until model refresh removes it from the DOM. Requires `_deleting.has(key)` class binding on the parent element. | +| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success; the mark is auto-purged after 2s (the WS delta normally removes the row sooner). Requires `_deleting.has(key)` class binding on the parent element. | +| `onComplete` | Callback after a successful deletion. Wire it to `modelFetch()` for non-state models. | #### `ActionButton(props)` -Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. Shows a spinner animation during API calls and auto-disables the button to prevent double-submit. +Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`. ```javascript ActionButton({ @@ -925,7 +991,7 @@ ActionButton({ label: 'Apply', successMsg: 'Applied', errorType: 'error', // optional, defaults to 'error' - refresh: 'dnsmasq', // model name(s) to refresh + onSuccess: () => { /* optional, runs after the success toast */ }, cls: 'btn btn-outline', // optional disabled: false, }) @@ -937,7 +1003,6 @@ ActionButton({ labelOn: 'Disable', labelOff: 'Enable', condition: z.masquerade, - refresh: 'firewall', }) ``` @@ -953,7 +1018,8 @@ ActionButton({ | `condition` | Toggle condition for `labelOn`/`labelOff` | | `successMsg` | Success toast message | | `errorType` | Toast type for errors (default: `'error'`) | -| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success | +| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. | +| `onSuccess` | Callback after the success toast. Wire it to `modelFetch()` for non-state models (e.g., `backends`). | | `cls` | Button CSS classes (default: `'btn btn-outline'`) | | `disabled` | Disabled state | @@ -968,8 +1034,8 @@ ActionCell({ removeUrl: '/api/proxy/domains/' + enc(d.domain), removeMessage: 'Remove proxy for ' + d.domain + '?', removeSuccess: 'Domain removed', - removeRefresh: 'proxy', removeLabel: 'Delete', + deleteKey: d.domain, }) ``` @@ -982,7 +1048,7 @@ ActionCell({ | `removeUrl` | API DELETE URL | | `removeMessage` | Confirmation prompt text | | `removeSuccess` | Success toast message | -| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete | +| `removeRefresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. | | `removeLabel` | Delete button label (default: `'Remove'`) | | `removeBody` | Optional JSON body to send with DELETE | | `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) | @@ -1098,7 +1164,6 @@ Table({ url: '/api/item/' + enc(i.id), message: 'Delete ' + esc(i.name) + '?', success: 'Item removed', - refresh: 'firewall', })), )), emptyText: 'No items', @@ -1167,7 +1232,6 @@ const addZone = QuickModal({ validate: (b) => !b.name ? 'Name required' : null, successMsg: 'Zone created', // or (data) => string }, - refresh: 'firewall', // model name(s) to refresh after success }); // Usage in render: @@ -1183,9 +1247,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone') | `submit.url` | API URL or `(data) => string` | | `submit.method` | HTTP method (default: `'POST'`) | | `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) | -| `submit.validate` | `(body) => string | null`, validation function | +| `submit.validate` | `(body) => string \| null`, validation function | | `submit.successMsg` | Success toast message or `(data) => string` | -| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success | +| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. | | `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit | | `submitLabel` | Submit button label (default: `'Submit'`) | @@ -1201,7 +1265,6 @@ const editIface = MultiSelectModal({ selected: zone.interfaces, fieldKey: 'interfaces', successMsg: 'Interfaces updated', - refresh: 'firewall', }); // Usage: @@ -1218,7 +1281,7 @@ h('button', { 'on:click': editIface }, 'Edit') | `selected` | Currently selected values (`string[]`) | | `fieldKey` | JSON key for the submitted field | | `successMsg` | Success toast message (default: `'Updated'`) | -| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success | +| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. | ### Toast @@ -1247,10 +1310,10 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching. ## Conventions - **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`. -- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. The model layer handles fetching, loading states, error handling, and WS-driven refresh. Pages never call `apiFetch` in `load()`. +- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`. - **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode. - **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags. -- **Mutation refresh**: UI components use `refresh: 'model_name'` to trigger `modelFetch()` after API mutations. Accepts single name or array. +- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`). - **Keys** on list items use unique identifiers (`item.id`), not array indices. - **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments. - **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content. diff --git a/docs/overview.md b/docs/overview.md index 76642ee..d64bdea 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy ## Architecture Overview -Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication. +Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx (TLS termination only — management authentication is a Flask-layer JWT, not nginx basic auth; individual proxy domains may optionally configure their own basic auth). ## Subsystems diff --git a/docs/security.md b/docs/security.md index 6e1b4cd..75aba91 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,7 +11,7 @@ ACME certificate operations via `acme.sh` run as the daemon user — not as root This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo. -Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token as a query parameter for validation before upgrade. +Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer ` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades. ## Communication Between WebUI and Daemon diff --git a/docs/state-model.md b/docs/state-model.md new file mode 100644 index 0000000..e3e929d --- /dev/null +++ b/docs/state-model.md @@ -0,0 +1,174 @@ +# State Model Reference + +Authoritative reference for the shapes returned by the daemon's +pre-computed state store (`lib/state.py`), collected per subsystem and +pushed over the WebSocket (snapshot on connect, per-subsystem deltas +after every change). + +Python schemas live in `lib/schema.py` (TypedDicts); each collector's +return annotation references them. + +## Shared notes + +- Every collector return carries a top-level `timestamp` (ISO-8601). +- Subsystems with a declarative config expose pending state as a status + dict: `status: {"pending_changes": bool}`, **except firewall**, which + uses `pending: {config_pending() result}`. +- A subsystem whose collection failed holds `null`/`None` in the state + store — WS snapshots and deltas skip `null` payloads so a failed + collector never overwrites good client data. + +## State shape summary + +`state_store.get()` returns: + +| Subsystem | Poll | Volatile fields | Top-level keys | +|---|---|---|---| +| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `zones`, `rich_rules`, `pending`, `timestamp` | +| `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` | +| `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` | +| `acme` | 300s | *(none)* | `certs`, `email`, `account`, `timestamp` | +| `wireguard` | 10s | `status.peers[].transfer_received`/`.transfer_sent`/`.latest_handshake` and the same three under `status.classes[].peers[]` | `config`, `status`, `peers`, `timestamp` | +| `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` | +| `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` | + +Poll intervals are overridable via `VACUUM_WALL_POLL_INTERVALS` +(`subsystem:seconds,subsystem:seconds`). + +## Firewall + +Top-level `FirewallState`: + +``` +{ + config: {}, // config/firewall/config.json + active_zones: {zone: [iface]}, // zones with assigned interfaces + interfaces: [ // ip link/addr parsing + {name, mac, state, mtu, ips, ipv6, zone} + ], + available_services: [str], // firewall-cmd --get-services + zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys, + // may carry "sources", "ports", + // "protocols", "forward-ports", "ics", + // "icmp-blocks", "module", "rich-rules" + rich_rules: {zone: [str]}, // raw firewalld rich-rule strings, + // re-derived from zones (NO ids — + // deletion-by-id uses config.zones[].rich_rules) + pending: { // config_pending() (lib/firewall.py) + pending: [...], needs_apply: bool, + unmanaged_zones: {zone: {interfaces: [...]}} + }, + timestamp: str, +} +``` + +Notes: +- `interfaces[].ips` / `interfaces[].ipv6` hold `"ip/prefix"` strings + (IPv6 list is separate). +- The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`); + `state.rich_rules` is the snake_case top-level re-derivation. + +## Dnsmasq + +``` +{ + config: {}, // config/dnsmasq/config.json, deep-merged + status: { + service_active: bool, config_file_exists: bool, + active_leases: int, pending_changes: bool + }, + leases: [ + {expires, mac, ip, hostname, interface} // expires = ISO-8601 or "" + ], + timestamp: str, +} +``` + +## Nginx + +``` +{ + config: {}, // config/nginx/config.json + domains: [ // flattened: one entry per domain+path + {domain, path, backend, online, force_ssl, backend_name, cert, + [is_management], [is_websocket]} + ], + status: {pending_changes: bool}, + timestamp: str, +} +``` + +## ACME + +``` +{ + certs: [ // list_certs(); extra keys possible + {domain, expiry, renewed, status, days_remaining, ...} + ], + email: str, + account: {registered, email, ca, key_length}, + timestamp: str, +} +``` + +## WireGuard + +``` +{ + config: {}, // private_key stripped from interface + // AND every access class + status: { + up: bool, // true when ANY managed iface is up + interface: {}, peers: [], // legacy single interface (wg0) + classes: {class: {up, interface, peers}}, // per wg- + pending_changes: bool + }, + peers: [ // config peers, private keys stripped + {name, public_key, endpoint, allowed_ips, + persistent_keepalive, preshared_key, ...} + ], + timestamp: str, +} +``` + +Runtime peers (`status.peers[]`, `status.classes[].peers[]`) carry: +`public_key`, `endpoint`, `allowed_ips`, `latest_handshake`, +`transfer_received`, `transfer_sent`, `persistent_keepalive`. + +## Networkd + +Matches `parse_networkctl_status()` output (lib/network.py) exactly: + +``` +{ + config: {}, // config/network/config.json + interfaces: {iface: { // flat runtime entry per interface; + addresses: ["ip/prefix"], // a single combined addresses list + gateway, dns: [str], mac, // (no ipv6_addresses/routes keys) + state, link} + }, + status: {pending_changes: bool}, + timestamp: str, +} +``` + +The parser does not filter `lo`; clients that don't want it filter +client-side. + +## System + +Metrics only — no config, no pending state. + +``` +{ + load: {load1, load5, load15}, + memory: {total, available, used, used_pct}, // bytes; 0-100 + swap: {total, used, used_pct}, // bytes; 0-100 + traffic: {iface: {rx_bytes, tx_bytes, + rx_packets, tx_packets}}, + timestamp: str, +} +``` + +All four metric fields are volatile (1s tick cadence); structural diffs +only fire on interface-set changes. diff --git a/lib/schema.py b/lib/schema.py new file mode 100644 index 0000000..c95700f --- /dev/null +++ b/lib/schema.py @@ -0,0 +1,485 @@ +"""TypedDict schemas for every state collector's return value. + +Single source of truth for the state-store data shapes. The Markdown +reference is ``docs/state-model.md``. +""" + +from typing import Any, TypedDict + +__all__ = [ + "AcmeAccount", + "AcmeCert", + "AcmeState", + "CpuLoad", + "DnsmasqDhcpLease", + "DnsmasqState", + "DnsmasqStatus", + "FirewallInterface", + "FirewallState", + "FirewallZone", + "MemoryStats", + "NetworkdInterface", + "NetworkdState", + "NginxDomain", + "NginxState", + "SwapStats", + "SystemState", + "TrafficStats", + "WgClassStatus", + "WgPeer", + "WgState", + "WgStatus", + "WgStatusPeer", +] + + +# ── Shared notes ─────────────────────────────────────────────── +# Every collector return carries a top-level `timestamp` (ISO-8601). +# Subsystems with a declarative config expose pending state as a +# status dict: `status: {"pending_changes": bool}`, except firewall, +# which uses `pending: {config_pending() result}`. + +# ── Firewall ────────────────────────────────────────────────── + + +class FirewallInterface(TypedDict): + """A network interface as parsed from `ip link` / `ip addr`. + + Attributes: + name: Interface name (e.g. "eth0"). + mac: MAC address, or ``None`` if unavailable. + state: Link state from `ip link` ("UP", "DOWN", "UNKNOWN", ...). + mtu: MTU value, or ``None`` if unavailable. + ips: IPv4 addresses as "ip/prefix" strings. + ipv6: IPv6 addresses as "ip/prefix" strings. + zone: Assigned firewalld zone name, or ``None``. + """ + + name: str + mac: str | None + state: str + mtu: int | None + ips: list[str] + ipv6: list[str] + zone: str | None + + +class FirewallZone(TypedDict, total=False): + """A firewalld zone as parsed from `--list-all-zones`. + + The zone dict carries the HYPHENATED key "rich-rules" (see + ``lib.firewall._parse_all_zones_output``), which TypedDict fields + cannot express. Additional firewalld keys may also appear: + "sources", "ports", "protocols", "forward-ports", "ics", + "icmp-blocks", "module", "rich-rules". + """ + + target: str + interfaces: list[str] + services: list[str] + masquerade: bool + # snake_case `rich_rules` exists only at the top-level FirewallState + # (collector re-derivation, lib/state.py); the zone dict itself uses + # the hyphenated "rich-rules" key. + rich_rules: list[str] + + +class FirewallState(TypedDict): + """Complete firewalld state (collector: `_collect_firewall`). + + Attributes: + config: Contents of config/firewall/config.json. + active_zones: Zone name → assigned interfaces. + default_zone: firewalld default zone name ("--get-default-zone"); + catch-all zone for interfaces with no explicit assignment. + interfaces: All system interfaces (see FirewallInterface). + available_services: firewalld service catalog ("--get-services"). + zones: All zones as runtime dicts (see FirewallZone). + rich_rules: Zone name → raw firewalld rich-rule strings. + pending: config_pending() result: + ``{pending: [...], needs_apply: bool, + unmanaged_zones: {zone: {interfaces: [...]}}}``. + timestamp: ISO-8601 collection time. + """ + + config: dict[str, Any] + active_zones: dict[str, list[str]] + default_zone: str + interfaces: list[FirewallInterface] + available_services: list[str] + zones: dict[str, FirewallZone] + rich_rules: dict[str, list[str]] + pending: dict[str, Any] + timestamp: str + + +# ── Dnsmasq ─────────────────────────────────────────────────── + + +class DnsmasqDhcpLease(TypedDict): + """A single dnsmasq DHCP lease from the lease file. + + Attributes: + expires: ISO-8601 expiry timestamp, or "" if unparseable. + mac: Client MAC address. + ip: Leased IP address. + hostname: Client hostname (may be ""). + interface: Interface the lease was granted on (may be ""). + """ + + expires: str + mac: str + ip: str + hostname: str + interface: str + + +class DnsmasqStatus(TypedDict): + """Dnsmasq service status snapshot. + + Attributes: + service_active: Whether the dnsmasq systemd unit is active. + config_file_exists: Whether the rendered .conf is on disk. + active_leases: Count of currently active leases. + pending_changes: Whether the config is dirty vs the applied state. + """ + + service_active: bool + config_file_exists: bool + active_leases: int + pending_changes: bool + + +class DnsmasqState(TypedDict): + """Dnsmasq state (collector: `_collect_dnsmasq`). + + Attributes: + config: config/dnsmasq/config.json, deep-merged with defaults. + status: Service/config status (see DnsmasqStatus). + leases: Active DHCP leases. + timestamp: ISO-8601 collection time. + """ + + config: dict[str, Any] + status: DnsmasqStatus + leases: list[DnsmasqDhcpLease] + timestamp: str + + +# ── Nginx ───────────────────────────────────────────────────── + + +class NginxDomain(TypedDict, total=False): + """One flattened domain+path entry (see `_resolve_paths`). + + Attributes: + domain: Domain name (config key). + path: Path prefix for this entry. + backend: Resolved backend config dict. + online: Whether a site .conf exists on disk. + force_ssl: Redirect-to-HTTPS flag. + backend_name: Backend config key this domain points at. + cert: Certificate reference (e.g. "acme", "selfsigned", ...). + is_management: Management UI path marker. + is_websocket: WebSocket-capable path marker. + """ + + domain: str + path: str + backend: dict[str, Any] + online: bool + force_ssl: bool + backend_name: str + cert: str | None + is_management: bool + is_websocket: bool + + +class NginxState(TypedDict): + """Nginx state (collector: `_collect_nginx`). + + Attributes: + config: config/nginx/config.json. + domains: Flattened domain entries (one per domain+path). + status: ``{"pending_changes": bool}``. + timestamp: ISO-8601 collection time. + """ + + config: dict[str, Any] + domains: list[NginxDomain] + status: dict[str, Any] + timestamp: str + + +# ── ACME ────────────────────────────────────────────────────── + + +class AcmeAccount(TypedDict): + """ACME account status (see `_parse_account_conf`). + + Attributes: + registered: Whether an account exists. + email: Registered email ("" when not registered). + ca: Human-readable CA name ("" when not registered). + key_length: Account key size, or ``None``. + """ + + registered: bool + email: str + ca: str + key_length: int | None + + +class AcmeCert(TypedDict, total=False): + """One certificate entry from `list_certs()` output. + + Attributes: + domain: Certificate domain name. + expiry: Expiry date string. + renewed: Last renewal date string. + status: "valid" | "expired" | "active" | ... + days_remaining: Days until expiry. + + Additional keys from `lib.acme.list_certs()` output may appear. + """ + + domain: str + expiry: str + renewed: str + status: str + days_remaining: int + + +class AcmeState(TypedDict): + """ACME state (collector: `_collect_acme`). + + Attributes: + certs: Certificate list. + email: Registered ACME email. + account: Account status (see AcmeAccount). + timestamp: ISO-8601 collection time. + """ + + certs: list[AcmeCert] + email: str + account: AcmeAccount + timestamp: str + + +# ── WireGuard ───────────────────────────────────────────────── +# The collector tracks BOTH a legacy single interface (wg0) and one +# wg- interface per access class. + + +class WgStatusPeer(TypedDict): + """A runtime peer parsed from `wg show` output. + + Attributes: + public_key: Peer public key. + endpoint: Last-seen endpoint, or ``None``. + allowed_ips: Allowed IP/CIDR list. + latest_handshake: Human-readable last handshake time, or ``None``. + transfer_received: Received-bytes string from the transfer line. + transfer_sent: Sent-bytes string from the transfer line. + persistent_keepalive: Keepalive seconds, or ``None``. + """ + + public_key: str + endpoint: str | None + allowed_ips: list[str] + latest_handshake: str | None + transfer_received: str + transfer_sent: str + persistent_keepalive: int | None + + +class WgClassStatus(TypedDict): + """Runtime status for one wg- interface. + + Attributes: + up: Whether the class interface is up. + interface: ``{public_key, listen_port}`` (empty when down). + peers: Runtime peers of this class interface. + """ + + up: bool + interface: dict[str, Any] + peers: list[WgStatusPeer] + + +class WgStatus(TypedDict): + """Aggregated WireGuard runtime status. + + Attributes: + up: True when any managed interface is up. + interface: Legacy single-interface info (public_key, listen_port). + peers: Legacy single-interface runtime peers. + classes: Per-access-class runtime status (keyed by class name). + pending_changes: Whether the config is dirty vs the applied state. + """ + + up: bool + interface: dict[str, Any] + peers: list[WgStatusPeer] + classes: dict[str, WgClassStatus] + pending_changes: bool + + +class WgPeer(TypedDict, total=False): + """A config-file peer (``private_key`` stripped). + + Attributes: + name: Peer config key. + public_key: Peer public key. + endpoint: Configured endpoint, or ``None``. + allowed_ips: Allowed IP/CIDR list. + persistent_keepalive: Keepalive seconds, or ``None``. + preshared_key: Preshared key, or ``None``. + """ + + name: str + public_key: str + endpoint: str | None + allowed_ips: list[str] + persistent_keepalive: int | None + preshared_key: str | None + + +class WgState(TypedDict): + """WireGuard state (collector: `_collect_wireguard`). + + Attributes: + config: config/wireguard/config.json; ``private_key`` stripped + from the interface AND from every access class. + status: Runtime status (see WgStatus). + peers: Config peers, private keys stripped. + timestamp: ISO-8601 collection time. + """ + + config: dict[str, Any] + status: WgStatus + peers: list[WgPeer] + timestamp: str + + +# ── Networkd ────────────────────────────────────────────────── +# Matches parse_networkctl_status() output exactly (lib/network.py). +# There is a single combined `addresses` list — no separate +# `ipv6_addresses` or `routes` keys. + + +class NetworkdInterface(TypedDict): + """One networkd interface as parsed by `parse_networkctl_status`. + + Attributes: + addresses: "ip/prefix" entries, IPv4+IPv6 combined. + gateway: Default-route gateway, or ``None``. + dns: Configured DNS server list. + mac: MAC address, or ``None``. + state: OperationalState (e.g. "routable", "degraded", "off"). + link: Link type (e.g. "ether", "loopback", ...). + """ + + addresses: list[str] + gateway: str | None + dns: list[str] + mac: str | None + state: str + link: str + + +class NetworkdState(TypedDict): + """Networkd state (collector: `_collect_networkd`). + + Attributes: + config: config/network/config.json. + interfaces: Runtime state keyed by interface name. + status: ``{"pending_changes": bool}``. + timestamp: ISO-8601 collection time. + """ + + config: dict[str, Any] + interfaces: dict[str, NetworkdInterface] + status: dict[str, Any] + timestamp: str + + +# ── System ──────────────────────────────────────────────────── + + +class CpuLoad(TypedDict): + """CPU load averages (see /proc/loadavg). + + Attributes: + load1: 1-minute load average. + load5: 5-minute load average. + load15: 15-minute load average. + """ + + load1: float + load5: float + load15: float + + +class MemoryStats(TypedDict): + """Memory usage (see /proc/meminfo). + + Attributes: + total: Total memory in bytes. + available: Available memory in bytes. + used: Used memory in bytes. + used_pct: Used percentage (0-100), rounded to 0.1. + """ + + total: int + available: int + used: int + used_pct: float + + +class SwapStats(TypedDict): + """Swap usage (see /proc/meminfo). + + Attributes: + total: Total swap in bytes. + used: Used swap in bytes. + used_pct: Used percentage (0-100), rounded to 0.1. + """ + + total: int + used: int + used_pct: float + + +class TrafficStats(TypedDict): + """Per-interface traffic counters (see /sys/class/net//statistics). + + Attributes: + rx_bytes: Total bytes received. + tx_bytes: Total bytes transmitted. + rx_packets: Total packets received. + tx_packets: Total packets transmitted. + """ + + rx_bytes: int + tx_bytes: int + rx_packets: int + tx_packets: int + + +class SystemState(TypedDict): + """System-wide metrics (collector: `_collect_system`). + + Attributes: + load: CPU load averages (see CpuLoad). + memory: Memory usage (see MemoryStats). + swap: Swap usage (see SwapStats). + traffic: Per-interface counters keyed by interface name. + timestamp: ISO-8601 collection time. + """ + + load: CpuLoad + memory: MemoryStats + swap: SwapStats + traffic: dict[str, TrafficStats] + timestamp: str diff --git a/lib/state.py b/lib/state.py index 6d8d9ce..0a4cc31 100644 --- a/lib/state.py +++ b/lib/state.py @@ -12,6 +12,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any, ClassVar +from lib import schema from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc from lib.firewall import ( _parse_active_zones, @@ -36,7 +37,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = { "wireguard": 10, "dnsmasq": 10, "networkd": 10, - "system": 30, + "system": 1, # 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, @@ -125,6 +126,17 @@ class State: """ return self._data.get(subsystem) + def get_snapshot(self) -> dict[str, dict[str, Any] | None]: + """Return all subsystem state dicts. + + Used for the initial WS snapshot on connect. + + Returns: + Dict mapping every subsystem name to its state data + (``None`` when not populated or the last collection failed). + """ + return {name: self._data.get(name) for name in self.SUBSYSTEMS} + def set(self, subsystem: str, data: dict[str, Any] | None) -> None: """Set state data for *subsystem*. @@ -424,7 +436,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str: return "/".join(parts) -def _collect_firewall() -> dict[str, Any]: +def _collect_firewall() -> schema.FirewallState: """Return the complete current state of firewalld. Returns: @@ -433,6 +445,7 @@ def _collect_firewall() -> dict[str, Any]: """ active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) active = _parse_active_zones(active_raw) + default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip() services = run(["firewall-cmd", "--get-services"], sudo=True).split() or [] link_out = run(["ip", "-o", "link", "show"], sudo=True) addr_out = run(["ip", "-o", "addr", "show"], sudo=True) @@ -505,6 +518,7 @@ def _collect_firewall() -> dict[str, Any]: # Pending changes full_state = { "active_zones": active, + "default_zone": default_zone, "interfaces": ifaces, "available_services": services, "zones": zones, @@ -517,6 +531,7 @@ def _collect_firewall() -> dict[str, Any]: return { "active_zones": active, + "default_zone": default_zone, "interfaces": ifaces, "available_services": services, "zones": zones, @@ -544,7 +559,7 @@ register_volatile( # --------------------------------------------------------------------------- -def _collect_dnsmasq() -> dict[str, Any]: +def _collect_dnsmasq() -> schema.DnsmasqState: """Collect dnsmasq status, config, and leases. Returns: @@ -645,7 +660,7 @@ register_collector("dnsmasq", _collect_dnsmasq) # --------------------------------------------------------------------------- -def _collect_nginx() -> dict[str, Any]: +def _collect_nginx() -> schema.NginxState: """Collect nginx config and domains list. Returns: @@ -846,7 +861,7 @@ def _get_acme_email() -> str: return _read_acme_email() -def _collect_acme() -> dict[str, Any]: +def _collect_acme() -> schema.AcmeState: """Collect ACME certificate list and email. Returns: @@ -883,7 +898,7 @@ register_collector("acme", _collect_acme) # --------------------------------------------------------------------------- -def _collect_wireguard() -> dict[str, Any]: +def _collect_wireguard() -> schema.WgState: """Collect WireGuard config, per-class status, and peers. Returns: @@ -1129,7 +1144,7 @@ register_volatile( # --------------------------------------------------------------------------- -def _collect_networkd() -> dict[str, Any]: +def _collect_networkd() -> schema.NetworkdState: """Collect networkd interface state from networkctl. Returns: @@ -1212,7 +1227,7 @@ def _parse_meminfo() -> dict[str, Any]: return info -def _collect_system() -> dict[str, Any]: +def _collect_system() -> schema.SystemState: """Collect system-wide metrics: CPU load, memory, network traffic. Reads from /proc and /sys — no subprocess needed. @@ -1297,6 +1312,17 @@ def _collect_system() -> dict[str, Any]: register_collector("system", _collect_system) +register_volatile( + "system", + frozenset( + { + "load", + "memory", + "swap", + "traffic", + } + ), +) __all__ = [ diff --git a/tests/test-model-set.js b/tests/test-model-set.js new file mode 100644 index 0000000..b955aad --- /dev/null +++ b/tests/test-model-set.js @@ -0,0 +1,111 @@ +/** + * Tests for hoover/model.js modelSet() — the WS data-streaming entry point. + * + * modelSet() bypasses the fetch cycle: it assigns directly to the reactive + * model, clears loading unconditionally (schema defaults mean model.data is + * never null), clears error, and never sets refreshing. + * + * model.js imports only reactivity.js — DOM-free at import, so the tests + * run under plain node (same pattern as test-auth-model.js). + * + * Run with `node tests/test-model-set.js`. + */ + +import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js'; +import { SUBSYSTEMS } from '../webui/static/hoover/schema.js'; + +let passed = 0; +let failed = 0; +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function assertEq(a, b, msg) { + if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/** Deep equality for objects/arrays (assertEq is reference-based). */ +function assertDeep(a, b, msg) { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/* ── Tests ─────────────────────────────────────────────────── */ + +test('modelSet assigns data and clears loading unconditionally', () => { + modelRegister('firewall', { + subsystem: 'firewall', + defaultData: SUBSYSTEMS.firewall.defaults, + fetch: async () => ({}), + }); + const m = getModel('firewall'); + assertEq(m.loading, true, 'registering model is loading'); + assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'schema defaults pre-populated'); + assertEq(m.refreshing, false, 'not refreshing at rest'); + + modelSet('firewall', { zones: { public: {} }, pending: { pending: [] } }); + + assertEq(m.loading, false, 'real data ends the initial load'); + assertEq(m.refreshing, false, 'modelSet never sets refreshing'); + assertEq(m.error, null, 'modelSet clears error'); + assertDeep(m.data.zones?.public, {}, 'data assigned to the reactive model'); +}); + +test('modelSet is a no-op for an unregistered name', () => { + assertEq(typeof modelSet('no-such-model', { x: 1 }), 'undefined', 'no throw, no return'); +}); + +test('modelSet clears a fetch-set error and replaces error-state data', async () => { + modelRegister('dnsmasq', { + subsystem: 'dnsmasq', + defaultData: SUBSYSTEMS.dnsmasq.defaults, + fetch: async () => { throw new Error('boom'); }, + }); + const m = getModel('dnsmasq'); + await modelFetch('dnsmasq'); + assertEq(m.error, 'boom', 'fetch failure sets error'); + assertEq(m.loading, false, 'fetch failure clears loading'); + assertEq(m.data, SUBSYSTEMS.dnsmasq.defaults, 'failed fetch keeps schema defaults'); + + const delta = { leases: [{ mac: 'aa:bb', ip: '10.0.0.9' }] }; + modelSet('dnsmasq', delta); + assertEq(m.error, null, 'subsequent real data clears the error'); + assertEq(m.data.leases?.[0]?.mac, 'aa:bb', 'delta replaces default data'); +}); + +test('modelSet works repeatedly without flag corruption', () => { + modelRegister('system', { + subsystem: 'system', + defaultData: SUBSYSTEMS.system.defaults, + fetch: async () => ({}), + }); + const m = getModel('system'); + modelSet('system', { load: { load1: 0.1 } }); + modelSet('system', { load: { load1: 0.2 } }); + assertEq(m.loading, false, 'still not loading'); + assertEq(m.refreshing, false, 'still not refreshing'); + assertEq(m.error, null, 'no error'); + assertEq(m.data.load?.load1, 0.2, 'latest delta wins'); +}); + +/* ── Runner ────────────────────────────────────────────────── */ + +(async () => { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (e) { + console.error(` \u2717 ${name}: ${e.message}`); + failed++; + } + } + console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/tests/test-reconnect-fallback.js b/tests/test-reconnect-fallback.js new file mode 100644 index 0000000..04fbfba --- /dev/null +++ b/tests/test-reconnect-fallback.js @@ -0,0 +1,175 @@ +/** + * Tests for the 3-second HTTP-fallback contract (Phase 3e). + * + * app.js cannot be imported under node (it imports every page and touches + * the DOM), so this covers the model-layer contract the deferred fetch + * decides on: + * + * - a model registered with schema defaults keeps `loading: true` + * (data is never null — that's why the guard is `if (model.loading)`) + * - `modelSet()` or a completed `modelFetch()` clears `loading` + * - the `if (model.loading) modelFetch(name)` decision fires ONLY for + * still-loading models — a WS-delivered snapshot suppresses the HTTP + * fallback for that model + * + * Uses a fake setTimeout queue and recording fetch stubs (no timers, no + * network) — same pattern as test-auth-model.js. + * + * Run with `node tests/test-reconnect-fallback.js`. + */ + +import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js'; +import { SUBSYSTEMS } from '../webui/static/hoover/schema.js'; + +let passed = 0; +let failed = 0; +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function assertEq(a, b, msg) { + if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/** Deep equality for objects/arrays (assertEq is reference-based). */ +function assertDeep(a, b, msg) { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/* ── Stubs ─────────────────────────────────────────────────── */ + +/** Fake setTimeout queue — captures scheduled fallbacks, never runs them. */ +const _timers = []; +globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; +globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; + +/** Drain pending timers in order, like a real 3s elapse. */ +async function runTimers() { + while (_timers.length) { + const t = _timers.shift(); + if (t) await t.fn(); + } +} + +/** Register one state-backed model with recording fetch. */ +function registerModel(name) { + const calls = { count: 0 }; + modelRegister(name, { + subsystem: name, + defaultData: SUBSYSTEMS[name].defaults, + fetch: async () => { + calls.count++; + return { fetched: true, name }; + }, + }); + return calls; +} + +/* ── Mirrors app.js fetchInitialData() decision logic ───────── */ + +/** Schedule the per-model 3s fallback timers (as app.js does). */ +function scheduleFallbacks(names) { + for (const name of names) { + setTimeout(() => { + const model = getModel(name); + if (model.loading) { + modelFetch(name); + } + }, 3000); + } +} + +/* ── Tests ─────────────────────────────────────────────────── */ + +test('schema-defaulted model: loading stays true until modelSet or fetch completes', () => { + registerModel('firewall'); + const m = getModel('firewall'); + assertEq(m.loading, true, 'fresh model is loading'); + assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'data is schema defaults (never null)'); + assertEq(m.error, null, 'no error yet'); + + modelSet('firewall', { zones: { public: {} } }); + assertEq(m.loading, false, 'modelSet clears loading'); + + registerModel('dnsmasq'); + const d = getModel('dnsmasq'); + assertEq(d.loading, true, 'a different fresh model is still loading'); +}); + +test('3s fallback fetches only models still loading (snapshot suppresses HTTP)', async () => { + const firewallCalls = registerModel('firewall'); + const dnsmasqCalls = registerModel('dnsmasq'); + + scheduleFallbacks(['firewall', 'dnsmasq']); + // The WS snapshot arrived first for firewall only. + modelSet('firewall', { zones: { internal: {} } }); + + await runTimers(); + + assertEq(firewallCalls.count, 0, 'snapshot-delivered model: no HTTP fallback'); + assertEq(dnsmasqCalls.count, 1, 'still-loading model: HTTP fallback fired'); + + const fw = getModel('firewall'); + assertEq(fw.loading, false, 'firewall not loading'); + assertDeep(fw.data.zones?.internal, {}, 'firewall keeps the snapshot data, not fetch output'); + + const dm = getModel('dnsmasq'); + assertEq(dm.loading, false, 'completed fetch clears loading'); + assertEq(dm.data.fetched, true, 'dnsmasq got the fallback data'); +}); + +test('a re-fired decision never double-fetches a settled model', async () => { + const calls = registerModel('acme'); + scheduleFallbacks(['acme']); + await runTimers(); + assertEq(calls.count, 1, 'first fallback fetch'); + + // A later decision pass (e.g. reconnect path) must not re-fetch. + const model = getModel('acme'); + if (model.loading) modelFetch('acme'); + await runTimers(); + assertEq(calls.count, 1, 'no double fetch once settled'); +}); + +test('fallback fetch failure lands in model.error, self-heals on next data', async () => { + modelRegister('wireguard', { + subsystem: 'wireguard', + defaultData: SUBSYSTEMS.wireguard.defaults, + fetch: async () => { throw new Error('state not populated yet'); }, + }); + const m = getModel('wireguard'); + + setTimeout(() => { if (m.loading) modelFetch('wireguard'); }, 3000); + await runTimers(); + + assertEq(m.error, 'state not populated yet', 'failure sets model.error'); + assertEq(m.loading, false, 'failure still clears loading (finally)'); + assertEq(m.data, SUBSYSTEMS.wireguard.defaults, 'schema defaults preserved on failure'); + + modelSet('wireguard', { up: false }); + assertEq(m.error, null, 'next real data clears the error'); + assertEq(m.data.up, false, 'real data lands'); +}); + +/* ── Runner ────────────────────────────────────────────────── */ + +(async () => { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (e) { + console.error(` \u2717 ${name}: ${e.message}`); + failed++; + } + } + console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/tests/test-ws-handler.js b/tests/test-ws-handler.js new file mode 100644 index 0000000..7aacfe5 --- /dev/null +++ b/tests/test-ws-handler.js @@ -0,0 +1,220 @@ +/** + * Tests for hoover/websocket.js handleMessage() — WS data streaming. + * + * handleMessage is driven through a real connect() against a stubbed + * globalThis.WebSocket: we record the constructed instance and call its + * onmessage handler with serialized daemon→client messages, then assert the + * reactive model state. Covers the snapshot fast path, per-subsystem deltas + * (including the networkd→network mapping), null-payload guards, and that + * retired/legacy message types are ignored (no model mutation, no throw). + * + * websocket.js pulls in model.js → reactivity.js and auth_model.js + * (DOM-free at import), so it runs under plain node with stubbed globals. + * + * Run with `node tests/test-ws-handler.js`. + */ + +import { connect } from '../webui/static/hoover/websocket.js'; +import { modelRegister, getModel, modelSet } from '../webui/static/hoover/model.js'; +import { SUBSYSTEMS } from '../webui/static/hoover/schema.js'; + +let passed = 0; +let failed = 0; +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function assertEq(a, b, msg) { + if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/** Deep equality for objects/arrays (assertEq is reference-based). */ +function assertDeep(a, b, msg) { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/* ── Stubs ─────────────────────────────────────────────────── */ + +function makeStorage(initial = {}) { + const m = new Map(Object.entries(initial)); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + keys: () => [...m.keys()], + }; +} + +// Stub the TTL refresh timer so the node process never waits on it. +const _timers = []; +globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; +globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; + +globalThis.location = { protocol: 'http:', host: '127.0.0.1:9090' }; +globalThis.sessionStorage = makeStorage({ 'vw:access': 'tok-abc.def.ghi' }); +globalThis.document = { location: { hash: '#/dashboard' } }; +globalThis.window = { dispatchEvent: () => {}, addEventListener: () => {} }; + +/** Records each constructed WebSocket so tests can drive onmessage. */ +class FakeWebSocket { + static instances = []; + constructor(url, protocols) { + this.url = url; + this.protocols = protocols; + this.readyState = 1; + this.onopen = null; + this.onclose = null; + this.onerror = null; + this.onmessage = null; + FakeWebSocket.instances.push(this); + } + close() { this.readyState = 3; } + send() {} +} +globalThis.WebSocket = FakeWebSocket; + +/** Register the auth + state models the WS handler depends on. */ +function setupModels() { + modelRegister('auth', { subsystem: 'auth', fetch: async () => ({}) }); + modelSet('auth', { token: 'tok-abc.def.ghi', user: { username: 'admin' } }); + for (const [name, subsystem] of [ + ['firewall', 'firewall'], ['dnsmasq', 'dnsmasq'], ['nginx', 'nginx'], + ['acme', 'acme'], ['wireguard', 'wireguard'], ['network', 'networkd'], ['system', 'system'], + ]) { + modelRegister(name, { + subsystem, + defaultData: SUBSYSTEMS[subsystem].defaults, + fetch: async () => ({}), + }); + } +} + +/** Establish a fresh WS connection and return the recorded instance. */ +function freshConnect() { + const prev = FakeWebSocket.instances.at(-1); + if (prev && prev.readyState <= 1) { prev.onclose = null; prev.close(); } + connect(); + return FakeWebSocket.instances.at(-1); +} + +/** Feed one daemon→client message through the recorded instance. */ +function emit(inst, msg) { + inst.onmessage({ data: JSON.stringify(msg) }); +} + +const SNAPSHOT = { + type: 'snapshot', + data: { + firewall: { zones: { public: {} } }, + dnsmasq: { leases: [] }, + nginx: null, // collector failed → must be skipped + acme: { certs: [] }, + wireguard: { up: true }, + networkd: { interfaces: { eth0: {} } }, + system: { load: { load1: 0.5 } }, + }, +}; + +/* ── Tests ─────────────────────────────────────────────────── */ + +test('connect() sends the raw JWT as the Sec-WebSocket-Protocol subprotocol', () => { + setupModels(); + const inst = freshConnect(); + assert(inst, 'a WS instance was constructed'); + assertEq(inst.url, 'ws://127.0.0.1:9090/ws', 'WS URL from origin'); + assert(Array.isArray(inst.protocols), 'subprotocols passed'); + assertEq(inst.protocols[0], 'tok-abc.def.ghi', 'bare JWT (no Bearer prefix)'); +}); + +test('snapshot fast path sets every non-null model; null entries are skipped', () => { + setupModels(); + const inst = freshConnect(); + emit(inst, SNAPSHOT); + + assertDeep(getModel('firewall').data.zones?.public, {}, 'firewall patched'); + assertDeep(getModel('dnsmasq').data.leases, [], 'dnsmasq patched'); + assertDeep(getModel('acme').data.certs, [], 'acme patched'); + assertEq(getModel('wireguard').data.up, true, 'wireguard patched'); + assertDeep(getModel('network').data.interfaces?.eth0, {}, 'networkd mapped → network'); + assertEq(getModel('system').data.load?.load1, 0.5, 'system patched'); + + // nginx data was null — modelSet was skipped entirely. + const nginx = getModel('nginx'); + assertDeep(nginx.data, SUBSYSTEMS.nginx.defaults, 'null entry keeps schema defaults'); + assertEq(nginx.loading, true, 'null entry never clears loading'); + // Non-null models had loading cleared by modelSet. + assertEq(getModel('firewall').loading, false, 'loading cleared on real data'); +}); + +test('versions delta patches the mapped subsystem model', () => { + setupModels(); + const inst = freshConnect(); + const before = JSON.stringify(getModel('firewall').data); + emit(inst, { type: 'versions', subsystem: 'firewall', data: { zones: { dmz: {} } } }); + assert(getModel('firewall').data.zones?.dmz !== undefined, 'firewall delta applied'); + assertEq(JSON.stringify(getModel('dnsmasq').data), JSON.stringify(SUBSYSTEMS.dnsmasq.defaults), 'other models untouched'); +}); + +test('networkd delta maps to the network model', () => { + setupModels(); + const inst = freshConnect(); + emit(inst, { type: 'versions', subsystem: 'networkd', data: { interfaces: { lo: {} } } }); + assertDeep(getModel('network').data.interfaces?.lo, {}, 'networkd → network'); +}); + +test('a null payload delta never overwrites good data (defense in depth)', () => { + setupModels(); + const inst = freshConnect(); + emit(inst, SNAPSHOT); // networkd has data + const kept = getModel('network').data; + emit(inst, { type: 'tick', subsystem: 'networkd', data: null }); + assertDeep(getModel('network').data, kept, 'null data left model untouched'); + emit(inst, { type: 'versions', subsystem: 'firewall', data: null }); + assertDeep(getModel('firewall').data.zones?.public, {}, 'null firewall data left model untouched'); +}); + +test('retired/legacy message types are ignored (no mutation, no throw)', () => { + setupModels(); + const inst = freshConnect(); + emit(inst, SNAPSHOT); + const before = {}; + for (const n of ['firewall', 'dnsmasq', 'nginx', 'acme', 'wireguard', 'network', 'system']) { + before[n] = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading; + } + const legacy = [ + { type: 'versions', updated: { firewall: 1 } }, // legacy dict form + { type: 'tick', subsystems: ['firewall', 'wireguard'] }, // legacy array form + { type: 'refresh', topic: 'firewall' }, + { type: 'notify', topic: 'firewall' }, + { type: 'status', topic: 'firewall' }, + { type: 'unknown' }, + ]; + for (const msg of legacy) emit(inst, msg); + for (const n of Object.keys(before)) { + const now = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading; + assertEq(now, before[n], `model ${n} unchanged by legacy message`); + } +}); + +/* ── Runner ────────────────────────────────────────────────── */ + +(async () => { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (e) { + console.error(` \u2717 ${name}: ${e.message}`); + failed++; + } + } + console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/tests/test_api.py b/tests/test_api.py index 4e5fa32..47e73b6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -40,6 +40,11 @@ def _ne(func, **kw): return _patch(f"webui.api.network.{func}", **kw) +def _st(func, **kw): + """Patch daemon.client.{func} in the status blueprint namespace.""" + return _patch(f"webui.api.status.{func}", **kw) + + @pytest.fixture def client(): from flask import Flask @@ -883,3 +888,55 @@ class TestNetworkApplyAll: mock_post.side_effect = RuntimeError("apply failed") resp = client.post("/api/network/apply") assert resp.status_code == 500 + + +# ============================================================================ +# Status +# ============================================================================ + + +@pytest.fixture +def status_client(): + from flask import Flask + + from webui.api.status import bp as status_bp + + app = Flask(__name__) + app.register_blueprint(status_bp, url_prefix="/api/status") + return app.test_client() + + +class TestStatusRefresh: + def test_filtered_subsystems_passed_through(self, status_client): + """The subsystem body is forwarded to the daemon POST endpoint.""" + from daemon.iface import POST_STATUS_REFRESH + + with _st("post") as mock_post: + mock_post.return_value = {"firewall": {"zones": {}}} + resp = status_client.post( + "/api/status/refresh", json={"subsystems": ["firewall"]} + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert data["data"] == {"firewall": {"zones": {}}} + mock_post.assert_called_once_with( + POST_STATUS_REFRESH, {"subsystems": ["firewall"]} + ) + + def test_empty_body_forwards_empty_dict(self, status_client): + """An empty body becomes {} (daemon-side 'all subsystems' default).""" + from daemon.iface import POST_STATUS_REFRESH + + with _st("post") as mock_post: + mock_post.return_value = {} + resp = status_client.post("/api/status/refresh") + assert resp.status_code == 200 + mock_post.assert_called_once_with(POST_STATUS_REFRESH, {}) + + @_st("post") + def test_runtime_error(self, mock_post, status_client): + mock_post.side_effect = RuntimeError("no daemon") + resp = status_client.post("/api/status/refresh", json={}) + assert resp.status_code == 500 + assert resp.get_json()["ok"] is False diff --git a/tests/test_broadcast_versions_per_subsystem.py b/tests/test_broadcast_versions_per_subsystem.py new file mode 100644 index 0000000..5ee6803 --- /dev/null +++ b/tests/test_broadcast_versions_per_subsystem.py @@ -0,0 +1,100 @@ +"""Tests for broadcast_versions per-subsystem contract (daemon.server). + +broadcast_versions(subsystem) sends exactly one data-carrying message for +its subsystem — no legacy `updated` field — and skips the broadcast +entirely when the subsystem's state is None (collector failed). +""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import daemon.server as server + + +class TestBroadcastVersionsPerSubsystem: + def _ws(self): + ws = AsyncMock() + ws.send_str = AsyncMock() + server._ws_subscribers.add(ws) + return ws + + def test_only_target_subsystem_sent(self): + """Each subscriber gets one versions message carrying that subsystem.""" + store = MagicMock() + store.get.side_effect = lambda name: {"v": name} + ws = self._ws() + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("firewall")) + ws.send_str.assert_awaited_once() + msg = json.loads(ws.send_str.call_args[0][0]) + assert msg == { + "type": "versions", + "subsystem": "firewall", + "data": {"v": "firewall"}, + } + finally: + server._ws_subscribers.discard(ws) + + def test_data_per_subsystem_not_shared(self): + """The data payload is that subsystem's state, not another's.""" + store = MagicMock() + store.get.side_effect = lambda name: {"name": name} + ws = self._ws() + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("dnsmasq")) + asyncio.run(server.broadcast_versions("acme")) + msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list] + assert [(m["subsystem"], m["data"]) for m in msgs] == [ + ("dnsmasq", {"name": "dnsmasq"}), + ("acme", {"name": "acme"}), + ] + # No legacy diff field in any message. + for m in msgs: + assert "updated" not in m + finally: + server._ws_subscribers.discard(ws) + + def test_none_state_produces_no_message(self): + """A None payload (failed collection) is skipped — no clobber.""" + store = MagicMock() + store.get.return_value = None + ws = self._ws() + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("firewall")) + ws.send_str.assert_not_awaited() + finally: + server._ws_subscribers.discard(ws) + + def test_no_bump_called(self): + """broadcast_versions never bumps — callers own the version counter.""" + store = MagicMock() + store.get.return_value = {"a": 1} + store.bump = MagicMock() + ws = self._ws() + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("firewall")) + store.bump.assert_not_called() + finally: + server._ws_subscribers.discard(ws) + + def test_dead_subscriber_removed(self): + """A failing subscriber is pruned and healthy ones still receive data.""" + store = MagicMock() + store.get.return_value = {"a": 1} + dead = AsyncMock() + dead.send_str = AsyncMock(side_effect=Exception("broken")) + healthy = self._ws() + server._ws_subscribers.add(dead) + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("firewall")) + assert dead not in server._ws_subscribers + healthy.send_str.assert_awaited_once() + finally: + server._ws_subscribers.discard(dead) + server._ws_subscribers.discard(healthy) diff --git a/tests/test_firewall.py b/tests/test_firewall.py index dd48de2..cc34259 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -1,11 +1,11 @@ """Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary).""" -from unittest.mock import patch +from unittest.mock import MagicMock, call, patch import pytest from daemon.handlers import firewall as daemonfirewall -from daemon.server import NotFoundError +from daemon.server import ConflictError, NotFoundError from lib import firewall # --------------------------------------------------------------------------- @@ -467,6 +467,150 @@ class TestDaemonConfigApply: assert result["applied_zones"] == ["public"] +# --------------------------------------------------------------------------- +# Management-lockout guard: default zone must keep https or ssh +# --------------------------------------------------------------------------- + + +class TestDaemonMgmtLockoutGuard: + ZONES_OUT = "public\ninternal" + + @patch("daemon.handlers.firewall._default_zone", return_value="public") + def test_set_zone_services_blocks_default_zone(self, mock_dz): + with ( + patch( + "daemon.handlers.firewall.run", return_value=self.ZONES_OUT + ) as mock_run, + pytest.raises(ConflictError) as exc, + ): + daemonfirewall.set_zone_services( + None, {"zone": "public", "services": ["http"]} + ) + assert "https and ssh" in str(exc.value) + # Guard fires before any mutation: only the zone-existence check ran. + assert mock_run.call_args_list == [ + call(["firewall-cmd", "--get-zones"], sudo=True), + ] + + @patch("daemon.handlers.firewall._default_zone", return_value="public") + def test_set_zone_services_force_bypasses_guard(self, mock_dz): + with ( + patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT), + patch.object( + daemonfirewall, "_parse_zone_output", return_value={"services": []} + ), + patch.object(daemonfirewall, "_reload"), + patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), + patch.object(daemonfirewall, "_save_config") as mock_save, + patch.object(daemonfirewall, "bus") as mock_bus, + patch("daemon.handlers.firewall.refresh_state"), + ): + mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) + result = daemonfirewall.set_zone_services( + None, {"zone": "public", "services": ["http"], "force": True} + ) + assert result == {"zone": "public", "services": ["http"]} + cfg = mock_save.call_args[0][0] + assert cfg["zones"]["public"]["services"] == ["http"] + + @patch("daemon.handlers.firewall._default_zone", return_value="internal") + def test_set_zone_services_non_default_zone_allowed(self, mock_dz): + with ( + patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT), + patch.object( + daemonfirewall, "_parse_zone_output", return_value={"services": []} + ), + patch.object(daemonfirewall, "_reload"), + patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), + patch.object(daemonfirewall, "_save_config"), + patch.object(daemonfirewall, "bus") as mock_bus, + patch("daemon.handlers.firewall.refresh_state"), + ): + mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) + result = daemonfirewall.set_zone_services( + None, {"zone": "public", "services": []} + ) + assert result == {"zone": "public", "services": []} + + def test_would_remove_mgmt_keeps_https(self): + assert daemonfirewall._would_remove_mgmt("public", ["http", "https"]) is False + assert daemonfirewall._would_remove_mgmt("public", ["ssh"]) is False + + def test_would_remove_mgmt_fails_closed_on_error(self): + with patch( + "daemon.handlers.firewall._default_zone", side_effect=RuntimeError("boom") + ): + assert daemonfirewall._would_remove_mgmt("public", ["http"]) is True + + @patch("daemon.handlers.firewall._default_zone", return_value="default-zone") + def test_would_remove_mgmt_other_zone(self, mock_dz): + assert daemonfirewall._would_remove_mgmt("public", ["http"]) is False + + @patch( + "lib.firewall.get_config", + return_value={ + "zones": {"public": {"services": ["http"], "interfaces": ["eth0"]}} + }, + create=True, + ) + @patch("daemon.handlers.firewall._default_zone", return_value="public") + def test_config_apply_blocks_lockout_before_backup(self, mock_dz, mock_cfg): + with ( + patch("daemon.handlers.firewall._save_backup") as mock_backup, + pytest.raises(ConflictError) as exc, + ): + daemonfirewall._config_apply() + assert "https and ssh" in str(exc.value) + mock_backup.assert_not_called() + + @patch( + "lib.firewall.get_config", + return_value={ + "zones": { + "public": { + "target": "DEFAULT", + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, + }, + create=True, + ) + @patch( + "daemon.handlers.firewall.run", + return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n", + ) + @patch("daemon.handlers.firewall._default_zone", return_value="public") + def test_config_apply_force_bypasses_guard(self, mock_dz, mock_run, mock_cfg): + with ( + patch( + "daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json" + ), + patch( + "daemon.handlers.firewall._get_state", + return_value={"zones": {"public": {}}}, + ), + patch("daemon.handlers.firewall.refresh_state"), + ): + result = daemonfirewall._config_apply(force=True) + assert result["applied_zones"] == ["public"] + + @patch( + "daemon.handlers.firewall._config_apply", + return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"}, + ) + @patch("daemon.handlers.firewall.bus") + def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply): + mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) + with patch("daemon.handlers.firewall.refresh_state"): + daemonfirewall.config_apply(None, None) + mock_apply.assert_called_once_with(force=False) + mock_apply.reset_mock() + daemonfirewall.config_apply(None, {"force": True}) + mock_apply.assert_called_once_with(force=True) + + class TestDaemonConfigPending: @patch("lib.state.state") def test_returns_pending(self, mock_st): diff --git a/tests/test_polling.py b/tests/test_polling.py index a1a79ce..a655bc2 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -14,6 +14,11 @@ class TestPollIntervals: assert _POLL_INTERVALS["wireguard"] == 10 assert _POLL_INTERVALS["dnsmasq"] == 10 assert _POLL_INTERVALS["networkd"] == 10 + # Phase 5: real-time system metrics poll at 1s. + assert _POLL_INTERVALS["system"] == 1 + # nginx/acme derive from config files; poll for drift self-heal. + assert _POLL_INTERVALS["nginx"] == 60 + assert _POLL_INTERVALS["acme"] == 300 def test_env_override(self): """VACUUM_WALL_POLL_INTERVALS env var can override values.""" @@ -33,28 +38,31 @@ class TestPollIntervals: class TestBroadcastTick: def test_sends_tick_message(self): - from daemon.server import _ws_subscribers, broadcast_tick + import daemon.server as server mock_ws = AsyncMock() mock_ws.send_str = AsyncMock() - _ws_subscribers.add(mock_ws) + server._ws_subscribers.add(mock_ws) try: - asyncio.run(broadcast_tick(["firewall", "wireguard"])) + with patch.object(server.state_store, "get", return_value={"up": True}): + asyncio.run(server.broadcast_tick("firewall")) mock_ws.send_str.assert_called_once() sent = json.loads(mock_ws.send_str.call_args[0][0]) assert sent["type"] == "tick" - assert sent["subsystems"] == ["firewall", "wireguard"] + assert sent["subsystem"] == "firewall" + assert sent["data"] == {"up": True} finally: - _ws_subscribers.discard(mock_ws) + server._ws_subscribers.discard(mock_ws) def test_prunes_dead_subscribers(self): - from daemon.server import _ws_subscribers, broadcast_tick + import daemon.server as server mock_ws = AsyncMock() mock_ws.send_str = AsyncMock(side_effect=Exception("broken")) - _ws_subscribers.add(mock_ws) - asyncio.run(broadcast_tick(["firewall"])) - assert mock_ws not in _ws_subscribers + server._ws_subscribers.add(mock_ws) + with patch.object(server.state_store, "get", return_value={"up": True}): + asyncio.run(server.broadcast_tick("firewall")) + assert mock_ws not in server._ws_subscribers class TestPollTasks: diff --git a/tests/test_refresh_state_broadcast.py b/tests/test_refresh_state_broadcast.py new file mode 100644 index 0000000..5d52e0a --- /dev/null +++ b/tests/test_refresh_state_broadcast.py @@ -0,0 +1,144 @@ +"""Tests for refresh_state / refresh_status WS broadcasting (daemon.server). + +refresh_state() and refresh_status() re-collect state and broadcast a +data-carrying versions message for every (requested) subsystem so all +viewers stay in sync. +""" + +import asyncio +import json +from contextlib import suppress +from unittest.mock import AsyncMock, MagicMock, patch + +import daemon.server as server + + +def _run_and_drain(fn): + """Run *fn* inside a running event loop (required for broadcast tasks), + then drain the fire-and-forget broadcast tasks.""" + + async def drive(): + fn() + for _ in range(20): + await asyncio.sleep(0) + if not server._ws_tasks: + break + for task in list(server._ws_tasks): + with suppress(Exception): + await task + + asyncio.run(drive()) + + +def _new_ws(): + ws = AsyncMock() + ws.send_str = AsyncMock() + server._ws_subscribers.add(ws) + return ws + + +def _messages(ws): + return [json.loads(c[0][0]) for c in ws.send_str.call_args_list] + + +class TestRefreshStateBroadcast: + def test_broadcasts_each_requested_subsystem(self): + """refresh_state(["firewall","dnsmasq"]) broadcasts both, and bumps.""" + store = MagicMock() + store.get.side_effect = lambda name: {"s": name} + ws = _new_ws() + try: + with patch.object(server, "state_store", store): + _run_and_drain(lambda: server.refresh_state(["firewall", "dnsmasq"])) + store.populate.assert_called_once_with(["firewall", "dnsmasq"]) + store.bump.assert_any_call("firewall") + store.bump.assert_any_call("dnsmasq") + msgs = _messages(ws) + assert sorted(m["subsystem"] for m in msgs) == ["dnsmasq", "firewall"] + assert all(m["type"] == "versions" for m in msgs) + for m in msgs: + assert "updated" not in m + assert m["data"] == {"s": m["subsystem"]} + finally: + server._ws_subscribers.discard(ws) + + def test_failed_subsystem_skipped_others_buzz(self): + """A subsystem whose collection failed (None) is not broadcast.""" + store = MagicMock() + store.get.side_effect = lambda name: {"s": name} if name != "acme" else None + ws = _new_ws() + try: + with patch.object(server, "state_store", store): + _run_and_drain(lambda: server.refresh_state(["firewall", "acme"])) + subs = sorted(m["subsystem"] for m in _messages(ws)) + assert subs == ["firewall"] + finally: + server._ws_subscribers.discard(ws) + + def test_no_subsystems_arg_broadcasts_all(self): + """refresh_state() with no filter targets every subsystem.""" + from lib.state import State + + store = State() + for name in State.SUBSYSTEMS: + store.set(name, {"k": name}) + ws = _new_ws() + try: + with patch.object(server, "state_store", store): + _run_and_drain(lambda: server.refresh_state()) + subs = sorted(m["subsystem"] for m in _messages(ws)) + assert subs == sorted(State.SUBSYSTEMS) + finally: + server._ws_subscribers.discard(ws) + + +class TestRefreshStatusBroadcast: + def test_filtered_response_and_broadcast(self): + """POST /status/refresh replies only with the requested subsystems + and broadcasts each of them.""" + store = MagicMock() + store.get.side_effect = lambda name: {"s": name} + ws = _new_ws() + try: + with patch.object(server, "state_store", store): + + async def drive(): + request = MagicMock() + request.json = AsyncMock(return_value={"subsystems": ["firewall"]}) + response = await server.refresh_status(request) + await asyncio.sleep(0.01) + return response + + response = asyncio.run(drive()) + + body = json.loads(response.body) + assert body["ok"] is True + assert set(body["data"]) == {"firewall"} + store.bump.assert_not_called() + subs = sorted(m["subsystem"] for m in _messages(ws)) + assert subs == ["firewall"] + finally: + server._ws_subscribers.discard(ws) + + def test_no_body_returns_all_subsystems(self): + store = MagicMock() + store.get.side_effect = lambda name: {"s": name} + store.SUBSYSTEMS = ["firewall", "dnsmasq"] + ws = _new_ws() + try: + with patch.object(server, "state_store", store): + + async def drive(): + request = MagicMock() + request.json = AsyncMock(return_value=None) + response = await server.refresh_status(request) + await asyncio.sleep(0.01) + return response + + response = asyncio.run(drive()) + + body = json.loads(response.body) + assert body["ok"] is True + assert set(body["data"]) == {"firewall", "dnsmasq"} + finally: + server._ws_subscribers.discard(ws) diff --git a/tests/test_schema_types.py b/tests/test_schema_types.py new file mode 100644 index 0000000..ef8cff2 --- /dev/null +++ b/tests/test_schema_types.py @@ -0,0 +1,147 @@ +"""Tests that collector outputs match the lib.schema TypedDict shapes. + +Each collector's return value is asserted against its TypedDict's required +keys at runtime (subprocess/shell calls mocked — no system services). The +TypedDicts in lib/schema.py are the authoritative state-store contract; +these tests catch drift between the schemas and the collectors. +""" + +import json +from unittest.mock import Mock, patch + +import lib.state +from lib import schema + + +def _missing(required_keys: frozenset, data: dict) -> set[str]: + return set(required_keys) - set(data) + + +class TestCollectorShapesMatchSchema: + def test_firewall_state(self): + with patch.object(lib.state, "run") as mock_run: + + def run_side(args, **kwargs): + if "--get-active-zones" in args: + return "public\n eth0" + if "--get-services" in args: + return "ssh http" + if "--list-all-zones" in args: + return ( + "public\n" + " target: default\n" + " interfaces: eth0\n" + " services: \n" + " ports: \n" + " protocols: \n" + " forward-ports: \n" + " masquerade: no\n" + " rich rules: \n" + ) + if "ip" in args[0]: + if "link" in args: + return ( + "1: lo: mtu 65536\n" + "2: eth0: mtu 1500 link/ether aa:bb\n" + ) + if "addr" in args: + return "2: eth0 inet 192.168.1.1/24\n" + return "" + + mock_run.side_effect = run_side + result = lib.state._collect_firewall() + + assert not _missing(schema.FirewallState.__required_keys__, result) + for iface in result["interfaces"]: + for k in schema.FirewallInterface.__required_keys__: + assert k in iface, f"FirewallInterface missing {k}" + + def test_dnsmasq_state(self): + with patch.object(lib.state, "run_proc") as mock_proc: + mock_proc.return_value = Mock(stdout="active\n", returncode=0) + result = lib.state._collect_dnsmasq() + + assert not _missing(schema.DnsmasqState.__required_keys__, result) + for k in schema.DnsmasqStatus.__required_keys__: + assert k in result["status"], f"DnsmasqStatus missing {k}" + + def test_nginx_state(self): + result = lib.state._collect_nginx() + assert not _missing(schema.NginxState.__required_keys__, result) + assert "pending_changes" in result["status"] + + def test_acme_state(self): + with ( + patch.object(lib.state, "_get_acme_email", return_value="a@b.c"), + patch("lib.acme.list_certs", return_value=[]), + patch.object( + lib.state, + "_parse_account_conf", + return_value={"registered": False, "email": "", "ca": ""}, + ), + ): + result = lib.state._collect_acme() + + assert not _missing(schema.AcmeState.__required_keys__, result) + + def test_wireguard_state(self): + with patch.object(lib.state, "run_proc") as mock_proc: + mock_proc.return_value = Mock(stdout="", returncode=1) + result = lib.state._collect_wireguard() + + assert not _missing(schema.WgState.__required_keys__, result) + for k in schema.WgStatus.__required_keys__: + assert k in result["status"], f"WgStatus missing {k}" + assert "classes" in result["status"] + + def test_networkd_state(self): + networkctl = { + "Interfaces": [ + { + "Name": "eth0", + "Type": "ether", + "OperationalState": "routable", + "HardwareAddress": [1, 2, 3, 4, 5, 6], + "Addresses": [ + {"Address": [192, 168, 30, 50], "Family": 2, "PrefixLength": 24} + ], + "Routes": [ + { + "Family": 2, + "Destination": [0, 0, 0, 0], + "DestinationPrefixLength": 0, + "Gateway": [192, 168, 30, 1], + } + ], + "DNS": [{"Address": [1, 1, 1, 1], "Family": 2}], + } + ] + } + with patch.object(lib.state, "run", return_value=json.dumps(networkctl)): + result = lib.state._collect_networkd() + + assert not _missing(schema.NetworkdState.__required_keys__, result) + assert "eth0" in result["interfaces"] + entry = result["interfaces"]["eth0"] + for k in schema.NetworkdInterface.__required_keys__: + assert k in entry, f"NetworkdInterface missing {k}" + assert entry["gateway"] == "192.168.30.1" + assert entry["addresses"] == ["192.168.30.50/24"] + + def test_system_state(self): + """Reads /proc and /sys directly — no mocking needed on Linux.""" + result = lib.state._collect_system() + assert not _missing(schema.SystemState.__required_keys__, result) + for k in schema.CpuLoad.__required_keys__: + assert k in result["load"], f"CpuLoad missing {k}" + for k in schema.MemoryStats.__required_keys__: + assert k in result["memory"], f"MemoryStats missing {k}" + for k in schema.SwapStats.__required_keys__: + assert k in result["swap"], f"SwapStats missing {k}" + + def test_volatile_system_registered(self): + """Phase 5: system metrics are volatile (tick, not version bumps).""" + from lib.state import _VOLATILE + + expected = frozenset({"load", "memory", "swap", "traffic"}) + assert _VOLATILE.get("system") == expected diff --git a/tests/test_state.py b/tests/test_state.py index 6d99e80..2329162 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -28,6 +28,25 @@ class TestState: assert state is not None assert isinstance(state, State) + def test_get_snapshot_empty(self): + """Fresh store: snapshot lists every subsystem, all None.""" + s = State() + snap = s.get_snapshot() + assert set(snap) == set(s.SUBSYSTEMS) + assert all(v is None for v in snap.values()) + + def test_get_snapshot_reflects_set_and_none(self): + """Snapshot carries set data; failed collections stay None.""" + s = State() + s.set("firewall", {"zones": {}}) + s.set("system", {"load": {"load1": 0.0}}) + s.set("dnsmasq", None) + snap = s.get_snapshot() + assert snap["firewall"] == {"zones": {}} + assert snap["system"] == {"load": {"load1": 0.0}} + assert snap["dnsmasq"] is None + assert snap["acme"] is None + class TestCollectAll: @patch("lib.state.run") @@ -37,6 +56,8 @@ class TestCollectAll: def run_side(args, **kwargs): if "--get-active-zones" in args: return "public\n eth0" + if "--get-default-zone" in args: + return "public\n" if "--get-services" in args: return "ssh http" if "ip" in args[0]: @@ -60,6 +81,8 @@ class TestCollectAll: result = _collect_firewall() assert isinstance(result, dict) assert "active_zones" in result + assert "default_zone" in result + assert result["default_zone"] == "public" assert "interfaces" in result assert "timestamp" in result @@ -71,6 +94,8 @@ class TestCollectAll: def run_side(args, **kwargs): if "--get-active-zones" in args: return "public\n eth0\ninternal\n eth0.100" + if "--get-default-zone" in args: + return "public\n" if "--get-services" in args: return "ssh http" if "ip" in args[0]: diff --git a/tests/test_ws_delta.py b/tests/test_ws_delta.py new file mode 100644 index 0000000..6cefdd2 --- /dev/null +++ b/tests/test_ws_delta.py @@ -0,0 +1,114 @@ +"""Tests for WS delta structure (daemon.server._poll_loop + broadcasts). + +After the push-stream migration the poll loop drives per-subsystem deltas: +a structural diff bumps the version and broadcasts {type: versions, +subsystem, data}; a volatile-only diff broadcasts {type: tick, subsystem, +data}. No legacy `updated` dict / `subsystems` array is emitted. +""" + +import asyncio +import hashlib +import json +from contextlib import suppress +from unittest.mock import AsyncMock, MagicMock, patch + +import daemon.server as server +from lib.state import State + + +def _zero_offset_subsystem(interval: int) -> str: + """Find a subsystem name whose md5 offset is 0 so the loop starts at once.""" + for i in range(100_000): + name = f"sub{i}" + offset = int(hashlib.md5(name.encode()).hexdigest(), 16) % interval + if offset == 0: + return name + raise AssertionError("could not find zero-offset name") + + +def _run_one_poll_iteration(poll_result): + """Run _poll_loop for a single iteration and return the broadcast mocks.""" + namespaced = _zero_offset_subsystem(60) + + async def drive(): + store = MagicMock() + store.poll.return_value = poll_result + store.bump = MagicMock() + store.get.return_value = {"value": 1} + bv = AsyncMock() + bt = AsyncMock() + task = None + with ( + patch.object(server, "state_store", store), + patch.object(server, "broadcast_versions", bv), + patch.object(server, "broadcast_tick", bt), + patch.object(server, "blacklist_expired"), + ): + task = asyncio.create_task(server._poll_loop(namespaced, 60)) + await asyncio.sleep(0.02) # let one full iteration run + task.cancel() + with suppress(asyncio.CancelledError): + await task + return store, bv, bt + + store, bv, bt = asyncio.run(drive()) + return store, bv, bt + + +class TestPollLoopDeltas: + def test_structural_change_broadcasts_versions(self): + store, bv, bt = _run_one_poll_iteration((True, False)) + store.bump.assert_called_once_with(_zero_offset_subsystem(60)) + bv.assert_awaited_once() + bt.assert_not_awaited() + + def test_volatile_change_broadcasts_tick(self): + store, bv, bt = _run_one_poll_iteration((False, True)) + store.bump.assert_not_called() + bv.assert_not_awaited() + bt.assert_awaited_once() + + def test_no_change_no_broadcast(self): + store, bv, bt = _run_one_poll_iteration((False, False)) + store.bump.assert_not_called() + bv.assert_not_awaited() + bt.assert_not_awaited() + + +class TestDeltaMessageShape: + def test_versions_message_carries_subsystem_and_data(self): + """broadcast_versions emits {type, subsystem, data} — no `updated`.""" + store = State() + store.set("firewall", {"zones": {"public": {}}}) + ws = AsyncMock() + ws.send_str = AsyncMock() + server._ws_subscribers.add(ws) + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_versions("firewall")) + ws.send_str.assert_awaited_once() + msg = json.loads(ws.send_str.call_args[0][0]) + assert msg["type"] == "versions" + assert msg["subsystem"] == "firewall" + assert msg["data"] == {"zones": {"public": {}}} + assert "updated" not in msg + finally: + server._ws_subscribers.discard(ws) + + def test_tick_message_carries_subsystem_and_data(self): + store = State() + store.set("system", {"load": {"load1": 1.0}}) + ws = AsyncMock() + ws.send_str = AsyncMock() + server._ws_subscribers.add(ws) + try: + with patch.object(server, "state_store", store): + asyncio.run(server.broadcast_tick("system")) + ws.send_str.assert_awaited_once() + msg = json.loads(ws.send_str.call_args[0][0]) + assert msg["type"] == "tick" + assert msg["subsystem"] == "system" + assert msg["data"] == {"load": {"load1": 1.0}} + assert "subsystems" not in msg + finally: + server._ws_subscribers.discard(ws) diff --git a/tests/test_ws_snapshot.py b/tests/test_ws_snapshot.py new file mode 100644 index 0000000..3c29d07 --- /dev/null +++ b/tests/test_ws_snapshot.py @@ -0,0 +1,127 @@ +"""Tests for the WS connect snapshot (daemon.server._handle_ws). + +After the push-stream migration, a successful WS handshake sends a full +state snapshot ({type: snapshot, data: {subsystem: state|null, ...}}) +instead of the retired {type: init, versions: ...} message. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from lib.state import State + + +@pytest.fixture(autouse=False) +def db_reset(): + """Isolated in-memory DB so a builtin admin exists for token minting. + + Mirrors the autouse _db_reset fixture in tests/test_auth.py (the DB + singleton must be reset and pointed at SQLite :memory: before each test). + """ + import os + + from lib.db import get_db, reset_db_for_test + + reset_db_for_test() + old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None) + old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None) + old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None) + + os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" + os.environ["VACUUM_WALL_DB_PATH"] = ":memory:" + + get_db() # triggers builtin-admin seed on the empty :memory: DB + yield + reset_db_for_test() + if old_backend is not None: + os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend + if old_path is not None: + os.environ["VACUUM_WALL_DB_PATH"] = old_path + if old_seed is not None: + os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed + + +@pytest.fixture +def access_token(db_reset): + """Mint a real access token for the seeded builtin admin.""" + from lib.auth import generate_tokens + + tokens = generate_tokens("admin", {"firewall": "rw"}) + return tokens["access_token"] + + +class TestWsSnapshot: + def test_snapshot_sent_on_auth_connect(self, access_token): + """A valid JWT subprotocol yields a full snapshot after auth.""" + import asyncio + + import daemon.server as server + + store = State() + store.set("firewall", {"zones": {"public": {}}}) + store.set("system", {"load": {"load1": 0.1}}) + # Remaining subsystems stay None (not populated). + + ws = AsyncMock() + ws.prepare = AsyncMock() + ws.send_json = AsyncMock() + + request = MagicMock() + request.headers = {"Sec-WebSocket-Protocol": access_token} + + with ( + patch("aiohttp.web.WebSocketResponse", return_value=ws), + patch.object(server, "state_store", store), + ): + asyncio.run(server._handle_ws(request)) + + ws.send_json.assert_awaited_once() + payload = ws.send_json.call_args[0][0] + assert payload["type"] == "snapshot" + data = payload["data"] + # Every subsystem key is present (push-stream: no `init`/`versions` shape). + for name in State.SUBSYSTEMS: + assert name in data + assert data["firewall"] == {"zones": {"public": {}}} + assert data["system"] == {"load": {"load1": 0.1}} + # Unpopulated subsystems are present but None (partial snapshot). + assert data["dnsmasq"] is None + assert data["wireguard"] is None + + def test_no_snapshot_without_token(self): + """Missing token -> 401 JSON, no WS is opened, no snapshot sent.""" + import asyncio + + import daemon.server as server + + ws = AsyncMock() + request = MagicMock() + request.headers = {} + + with patch("aiohttp.web.WebSocketResponse") as mock_ctor: + result = asyncio.run(server._handle_ws(request)) + + assert result.status == 401 + mock_ctor.assert_not_called() + ws.send_json.assert_not_awaited() + + def test_no_snapshot_on_invalid_token(self, access_token): + """A token that fails validation -> 401, no snapshot sent.""" + import asyncio + + import daemon.server as server + + ws = AsyncMock() + request = MagicMock() + request.headers = {"Sec-WebSocket-Protocol": access_token} + + with ( + patch("aiohttp.web.WebSocketResponse") as mock_ctor, + patch("lib.auth.validate_token", return_value=None), + ): + result = asyncio.run(server._handle_ws(request)) + + assert result.status == 401 + mock_ctor.assert_not_called() + ws.send_json.assert_not_awaited() diff --git a/webui/api/status.py b/webui/api/status.py index 3c38b9f..79deda1 100644 --- a/webui/api/status.py +++ b/webui/api/status.py @@ -7,10 +7,15 @@ from __future__ import annotations import logging -from flask import Blueprint +from flask import Blueprint, request from daemon.client import get, post -from daemon.iface import GET_STATUS_PENDING, GET_SYSTEM_METRICS, POST_STATUS_APPLY_ALL +from daemon.iface import ( + GET_STATUS_PENDING, + GET_SYSTEM_METRICS, + POST_STATUS_APPLY_ALL, + POST_STATUS_REFRESH, +) from webui.api.common import _error, _ok logger = logging.getLogger(__name__) @@ -51,6 +56,23 @@ def apply_all(): return _error(str(exc), 500) +@bp.route("/refresh", methods=["POST"]) +def refresh(): + """Re-collect state from the daemon, optionally filtered by subsystem. + + Endpoint: + POST /api/status/refresh + Body: + {"subsystems": ["firewall"]} or {} for all. + """ + body = request.get_json(silent=True) or {} + try: + return _ok(post(POST_STATUS_REFRESH, body)) + except RuntimeError as exc: + logger.error("Failed to refresh state: %s", exc) + return _error(str(exc), 500) + + @bp.route("/system-metrics", methods=["GET"]) def system_metrics(): """Retrieve system-wide metrics. diff --git a/webui/static/app.js b/webui/static/app.js index c1522ef..d119aff 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -1,4 +1,5 @@ -import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js'; +import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js'; +import { SUBSYSTEMS } from '/static/hoover/schema.js'; import DashboardPage from '/static/pages/dashboard.js'; import InterfacesPage from '/static/pages/interfaces.js'; @@ -44,66 +45,47 @@ function getNav() { /* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */ modelRegister('auth', createAuthModel()); -modelRegister('firewall', { - subsystem: 'firewall', - fetch: async () => { - const [cfg, zones, services, interfaces, state] = await Promise.allSettled([ - apiFetch('/api/firewall/config'), - apiFetch('/api/firewall/zones'), - apiFetch('/api/firewall/services'), - apiFetch('/api/firewall/interfaces'), - apiFetch('/api/firewall/state'), - ]); - const result = {}; - if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {}; - else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed')); - if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {}; - else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed')); - if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || []; - else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed')); - if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || []; - else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed')); - if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {}; - return result; - }, -}); - -modelRegister('network', { - subsystem: 'networkd', - fetch: async () => { - const r = await apiFetch('/api/network/interfaces'); +/* ── State-backed models ──────────────────────────────────── */ +/* All state-backed models stream over the WS (snapshot on connect, + * per-subsystem deltas). The fetch below is the HTTP fallback: it hits + * POST /api/status/refresh with a subsystem filter and returns the + * subsystem state verbatim — the exact shape the state store holds. */ +function _stateModelFetch(subsystem) { + return async () => { + const r = await apiFetch('/api/status/refresh', { + method: 'POST', + body: { subsystems: [subsystem] }, + }); if (!r.ok) throw new Error(r.error); - return r.data || { interfaces: {} }; - }, -}); + const payload = r.data?.[subsystem]; + // Collector failure: the daemon returns null for that subsystem. + // Throw instead of returning {} so modelFetch keeps the current + // data (schema defaults) and sets model.error rather than + // clobbering it with an empty object. + if (payload == null) throw new Error(subsystem + ': state not populated yet'); + return payload; + }; +} -modelRegister('dnsmasq', { - subsystem: 'dnsmasq', - fetch: async () => { - const [cfg, status, leases] = await Promise.allSettled([ - apiFetch('/api/dhcp/config'), - apiFetch('/api/dhcp/status'), - apiFetch('/api/dhcp/leases'), - ]); - const result = {}; - if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {}; - else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed')); - if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {}; - else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed')); - if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || []; - else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed')); - return result; - }, -}); +// Each maps to one subsystem in the state store. Model name may differ +// from subsystem name (e.g. `network` → `networkd`). +const STATE_MODELS = [ + { name: 'firewall', subsystem: 'firewall' }, + { name: 'dnsmasq', subsystem: 'dnsmasq' }, + { name: 'nginx', subsystem: 'nginx' }, + { name: 'acme', subsystem: 'acme' }, + { name: 'wireguard', subsystem: 'wireguard' }, + { name: 'network', subsystem: 'networkd' }, + { name: 'system', subsystem: 'system' }, +]; -modelRegister('nginx', { - subsystem: 'nginx', - fetch: async () => { - const r = await apiFetch('/api/proxy/domains'); - if (!r.ok) throw new Error(r.error); - return { domains: r.data || [] }; - }, -}); +for (const { name, subsystem } of STATE_MODELS) { + modelRegister(name, { + subsystem, + defaultData: SUBSYSTEMS[subsystem].defaults, + fetch: _stateModelFetch(subsystem), + }); +} modelRegister('backends', { subsystem: 'nginx', @@ -114,44 +96,6 @@ modelRegister('backends', { }, }); -modelRegister('acme', { - subsystem: 'acme', - fetch: async () => { - const [listR, acctR] = await Promise.allSettled([ - apiFetch('/api/certs/list'), - apiFetch('/api/certs/account'), - ]); - const result = {}; - if (listR.status === 'fulfilled' && listR.value.ok) { - result.certs = listR.value.data || []; - } else if (listR.status === 'rejected' || !listR.value.ok) { - throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed')); - } - if (acctR.status === 'fulfilled' && acctR.value.ok) { - result.account = acctR.value.data || { registered: false, email: '', ca: '' }; - } - return result; - }, -}); - -modelRegister('wireguard', { - subsystem: 'wireguard', - fetch: async () => { - const [stR, pR, cfgR] = await Promise.allSettled([ - apiFetch('/api/wireguard/status'), - apiFetch('/api/wireguard/peers'), - apiFetch('/api/wireguard/config'), - ]); - const result = {}; - if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {}; - else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed')); - if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || []; - else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed')); - if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {}; - return result; - }, -}); - const LOG_TABS = { journal: '/api/logs/journal', 'nginx-access': '/api/logs/nginx/access', @@ -172,29 +116,20 @@ modelRegister('logs', { }, }); -modelRegister('status', { - subsystem: '*', - fetch: async () => { - const [pendingR, metricsR] = await Promise.allSettled([ - apiFetch('/api/status/pending'), - apiFetch('/api/status/system-metrics'), - ]); - const result = {}; - if (pendingR.status === 'fulfilled' && pendingR.value.ok) { - result.pending = pendingR.value.data || {}; - } - if (metricsR.status === 'fulfilled' && metricsR.value.ok) { - result.metrics = metricsR.value.data || {}; - } - return result; - }, -}); - /* ── Initial fetch (after auth check) ───────────────────────── */ function fetchInitialData() { - for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) { - modelFetch(name); + // State-backed models: first data arrives via the WS snapshot. + // If WS hasn't delivered data within 3s, fall back to HTTP. + for (const { name } of STATE_MODELS) { + setTimeout(() => { + const model = getModel(name); + if (model.loading) { // snapshot (or a prior fetch) hasn't completed + modelFetch(name); + } + }, 3000); } + // Non-state models fetch immediately + modelFetch('backends'); modelFetch('logs', 'journal'); } diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index d72164f..5f23cc3 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -268,8 +268,11 @@ export function formAction(fn) { * @param {string} [opts.method] - HTTP method (default: 'POST') * @param {function} [opts.body] - () => object, body builder * @param {function} [opts.validate] - (body) => string|null, validation function + * @param {function} [opts.confirm] - (body) => string|null; if a message is + * returned, a native confirm() dialog gates + * the submit; on approval the body gains + * force=true (server-side guard override) * @param {string} [opts.successMsg] - Success toast message - * @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch * @param {string} [opts.submitText] - Submit button text (default: 'Submit') * @returns {object[]} Array of action descriptors */ @@ -279,8 +282,8 @@ export function apiSubmit(opts) { method = 'POST', body, validate, + confirm, successMsg = 'Saved', - refresh, submitText = 'Submit', closeModal, } = opts; @@ -300,6 +303,13 @@ export function apiSubmit(opts) { const err = validate(b); if (err) { toast(err, 'error'); return; } } + if (confirm) { + const msg = confirm(b); + if (msg) { + if (!window.confirm(msg)) return; + b.force = true; + } + } refreshModals(); const res = await apiFetch(url, { method, body: b }); if (res.ok) { @@ -307,14 +317,10 @@ export function apiSubmit(opts) { let msg = successMsg; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); } toast(msg, 'success'); if (closeModal) closeModal(); - if (refresh) { - const models = Array.isArray(refresh) ? refresh : [refresh]; - await Promise.all(models.map(m => modelFetch(m))); - } + // No modelFetch — WS delta updates all affected subsystems. } else { toast(res.error || 'Failed', 'error'); } diff --git a/webui/static/hoover/components/applyconfirm.js b/webui/static/hoover/components/applyconfirm.js index 7301114..595f713 100644 --- a/webui/static/hoover/components/applyconfirm.js +++ b/webui/static/hoover/components/applyconfirm.js @@ -10,7 +10,6 @@ import { h } from '../vdom.js'; import { html } from '../html.js'; import { reactive } from '../reactivity.js'; import { apiFetch, toast } from '../api.js'; -import { modelFetch } from '../model.js'; import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js'; export const SUBSYSTEM_LIST = [ @@ -56,9 +55,10 @@ ${hasPending ? html`Confirm: Apply All Changes -
+
`); }); } @@ -123,7 +120,7 @@ async function openApplyModal(successMsg, refreshTargets) { * @param {string} [props.syncedLabel] - Synced button text (default: 'Synced') * @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced) * @param {string} [props.successMsg] - Success toast message (default: 'All changes applied') - * @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply + * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) */ export function ApplyConfirm(props = {}) { const label = props.label || 'Apply'; @@ -139,7 +136,7 @@ export function ApplyConfirm(props = {}) { toast(successMsg || 'All synced', 'info'); return; } - openApplyModal(successMsg, props.refresh); + openApplyModal(successMsg); }, }, props.pending ? label : syncedLabel); -} \ No newline at end of file +} diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js index bc9a49b..9b51eb9 100644 --- a/webui/static/hoover/components/data.js +++ b/webui/static/hoover/components/data.js @@ -7,7 +7,6 @@ import { h } from '../vdom.js'; import { esc } from '../helpers.js'; import { apiFetch, toast } from '../api.js'; -import { modelFetch } from '../model.js'; import { requestUpdate } from '../reactivity.js'; const _actionPending = new Map(); @@ -69,16 +68,16 @@ export function Card(props = {}) { } /** - * A Remove button that confirms, deletes via API, toasts, and refreshes models. - * When the response includes a ``synced`` array (list of subsystem names - * that were auto-updated), shows a secondary toast and refreshes those - * models. + * A Remove button that confirms, deletes via API, and toasts. State-store + * models update from the daemon's WS delta — no explicit refresh. When the + * response includes a ``synced`` array (list of subsystem names that were + * auto-updated), appends them to the success toast. * * @param {object} props * @param {string} props.url - API DELETE URL * @param {string} props.message - Confirmation prompt text * @param {string} [props.success] - Success toast message (default: 'Removed') - * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch + * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) * @param {string} [props.label] - Button text (default: 'Remove') * @param {object} [props.body] - Optional JSON body to send with DELETE * @param {string} [props.deleteKey] - Unique ID for pending-delete row styling @@ -104,31 +103,20 @@ export function ConfirmDelete(props = {}) { let msg = props.success || 'Removed'; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); } toast(msg, 'success'); if (props.deleteKey) { _deleting.add(props.deleteKey); + // The WS delta (~50ms) removes the deleted item from + // model.data and re-renders the row away. This timeout + // purges _deleting if the delta is slow or the row was + // already unmounted. + setTimeout(() => _deleting.delete(props.deleteKey), 2000); } - if (props.refresh) { - const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; - const promises = names.map(n => modelFetch(n)); - - if (props.deleteKey && promises.length) { - Promise.all(promises).finally(() => { - _deleting.delete(props.deleteKey); - if (props.onComplete) props.onComplete(); - }); - } - } else if (props.deleteKey) { - _deleting.delete(props.deleteKey); - } - - if (props.onComplete && !props.refresh) { - props.onComplete(); - } + if (props.onComplete) props.onComplete(); + // No modelFetch — WS delta updates state store models. } else { toast(r.error || 'Failed', 'error'); } @@ -141,11 +129,11 @@ export function ConfirmDelete(props = {}) { } /** - * An action button that POSTs to an API endpoint, toasts on result, - * and optionally refreshes models. Supports toggle labels for on/off buttons. - * When the response includes a ``synced`` array (list of subsystem names - * that were auto-updated), shows a secondary toast and refreshes those - * models. + * An action button that POSTs to an API endpoint and toasts on result. + * Supports toggle labels for on/off buttons. State-store models update from + * the daemon's WS delta — no explicit refresh. When the response includes a + * ``synced`` array (list of subsystem names that were auto-updated), appends + * them to the success toast. * * @param {object} props * @param {string} props.url - API URL @@ -157,7 +145,8 @@ export function ConfirmDelete(props = {}) { * @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff * @param {string} [props.successMsg] - Success toast message * @param {string} [props.errorType] - Toast type for errors (default: 'error') - * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch + * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) + * @param {function} [props.onSuccess] - Callback after the success toast * @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline') * @param {boolean} [props.disabled] - Disabled state */ @@ -187,13 +176,10 @@ export function ActionButton(props = {}) { if (synced && synced.length) { if (msg) msg += ' '; msg += '(auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); } if (msg) toast(msg, 'success'); - if (props.refresh) { - const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; - names.forEach(n => modelFetch(n)); - } + if (props.onSuccess) props.onSuccess(); + // No modelFetch — WS delta updates state store models. } else { toast(resp.error || 'Failed', props.errorType || 'error'); } @@ -324,7 +310,7 @@ export function ServiceStatus(props = {}) { * @param {string} props.removeUrl - API DELETE URL * @param {string} props.removeMessage - Confirmation prompt text * @param {string} [props.removeSuccess] - Success toast message - * @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh + * @param {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat) * @param {string} [props.removeLabel] - Delete button label (default: 'Remove') * @param {object} [props.removeBody] - Optional JSON body to send with DELETE * @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline') diff --git a/webui/static/hoover/components/modal.js b/webui/static/hoover/components/modal.js index ea2ec06..06c4824 100644 --- a/webui/static/hoover/components/modal.js +++ b/webui/static/hoover/components/modal.js @@ -217,7 +217,8 @@ export function formModal(inner, title, fields, actions) { * @param {string[]} props.selected - Currently selected values * @param {string} props.fieldKey - JSON key for the field * @param {string} [props.successMsg] - Success toast message - * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch + * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) + * @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit * @returns {function} () => void, calls openModal */ export function MultiSelectModal(props = {}) { @@ -242,6 +243,7 @@ export function MultiSelectModal(props = {}) { }), successMsg: props.successMsg || 'Updated', refresh: props.refresh, + confirm: props.confirm, closeModal: () => closeModal(), }), ], @@ -263,7 +265,7 @@ export function MultiSelectModal(props = {}) { * @param {function} [props.submit.body] - (data) => object * @param {function} [props.submit.validate] - (body) => string|null * @param {string|function} [props.submit.successMsg] - Toast message or (data) => string - * @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch + * @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) * @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit) * @param {string} [props.submitLabel] - Submit button label (default: 'Submit') * @returns {function} (data) => void, calls openModal diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index d140dfa..c4a6dc3 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -23,7 +23,7 @@ export { definePage, hComp } from './component.js'; export { createRouter, Link } from './router.js'; /* ── WebSocket ───────────────────────────────────────────────── */ -export { connect, onMessage, disconnect } from './websocket.js'; +export { connect, disconnect } from './websocket.js'; /* ── API & Toast ─────────────────────────────────────────────── */ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } @@ -38,7 +38,7 @@ export { createAuthModel, getAuthToken, isAuthenticated, refreshAuth, getAuthDat from './auth_model.js'; /* ── Model ───────────────────────────────────────────────────── */ -export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js'; +export { modelRegister, getModel, modelFetch, modelSet, collectLoadingModels } from './model.js'; /* ── Helpers ─────────────────────────────────────────────────── */ export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js'; diff --git a/webui/static/hoover/model.js b/webui/static/hoover/model.js index b49ad78..cfbd312 100644 --- a/webui/static/hoover/model.js +++ b/webui/static/hoover/model.js @@ -135,6 +135,23 @@ export function modelFetch(name, signalOrParam, signal) { return promise; } +/** + * Set model data from WebSocket. Bypasses fetch cycle, no refreshing flag. + * Directly assigns to reactive proxy → triggers re-render. + * Clears loading unconditionally on arrival of real data. + * + * @param {string} name - Model name + * @param {any} data - State payload from the WS snapshot/delta + */ +export function modelSet(name, data) { + const entry = _models.get(name); + if (!entry) return; + const model = entry.model; + if (model.loading) model.loading = false; // real data ends the initial load + model.data = data; + model.error = null; +} + /** * Refresh all models whose subsystem topic matches the given topic. * Topic '*' matches every model. Model subsystem '*' matches every topic. diff --git a/webui/static/hoover/schema.js b/webui/static/hoover/schema.js new file mode 100644 index 0000000..4e3fa35 --- /dev/null +++ b/webui/static/hoover/schema.js @@ -0,0 +1,80 @@ +/** + * Hoover — schema.js + * + * State-store schema defaults — one module per subsystem. + * + * `defaults` initializes model.data so pages don't need null guards + * during the first render (before the WS snapshot or HTTP fallback + * delivers real data). The shapes match the daemon state store + * (docs/state-model.md); the WebSocket streams these exact shapes. + * + * `POLL_INTERVALS` is client-side awareness of the daemon's expected + * refresh cadence per subsystem (for "last updated" displays). + */ + +export const SUBSYSTEMS = { + firewall: { + defaults: { + config: {}, + active_zones: {}, + interfaces: [], + available_services: [], + zones: {}, + rich_rules: {}, + pending: {}, + }, + }, + dnsmasq: { + defaults: { + config: {}, + status: { service_active: false, config_file_exists: false, active_leases: 0, pending_changes: false }, + leases: [], + }, + }, + nginx: { + defaults: { + config: {}, + domains: [], + status: { pending_changes: false }, + }, + }, + acme: { + defaults: { + certs: [], + email: '', + account: { registered: false, email: '', ca: '', key_length: null }, + }, + }, + wireguard: { + defaults: { + config: {}, + status: { up: false, interface: {}, peers: [], classes: {}, pending_changes: false }, + peers: [], + }, + }, + networkd: { + defaults: { + config: {}, + interfaces: {}, + status: { pending_changes: false }, + }, + }, + system: { + defaults: { + load: { load1: 0, load5: 0, load15: 0 }, + memory: { total: 0, available: 0, used: 0, used_pct: 0 }, + swap: { total: 0, used: 0, used_pct: 0 }, + traffic: {}, + }, + }, +}; + +export const POLL_INTERVALS = { + firewall: 30, + wireguard: 10, + dnsmasq: 10, + networkd: 10, + system: 1, // Phase 5: 30 → 1 (real-time metrics) + nginx: 60, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal) + acme: 300, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal) +}; diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index e349d5f..c26a7e6 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -1,9 +1,10 @@ /** * Hoover — websocket.js * - * WebSocket connection manager with auto-reconnect. WS messages are routed - * to model-based refresh and direct onMessage handlers. - * Page-level subscribe/unsubscribe is replaced by the model layer. + * WebSocket connection manager with auto-reconnect. WS messages carry + * state data directly: a full snapshot on connect, then per-subsystem + * deltas. handleMessage patches the matching models in place via + * modelSet — no HTTP round-trip for auto-refresh. * * The JWT is read from the auth model (single source of truth). After 3 * failed close attempts a token refresh is triggered through the auth @@ -19,9 +20,21 @@ * reloaded; the UI keeps working via the REST API. */ -import { refreshByTopic } from './model.js'; +import { modelSet } from './model.js'; import { refreshAuth, getAuthToken } from './auth_model.js'; +// Maps subsystem name → registered model name. +// Most subsystems use the same name. `networkd` maps to `network`. +const _SUBSYSTEM_TO_MODEL = { + firewall: 'firewall', + dnsmasq: 'dnsmasq', + nginx: 'nginx', + acme: 'acme', + wireguard: 'wireguard', + networkd: 'network', + system: 'system', +}; + let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; @@ -32,9 +45,6 @@ let _wsRefreshStreak = 0; let _wsGivingUp = false; let _wsClosingHandled = false; -/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ -const _directHandlers = []; - /** * Build the WebSocket URL from the current origin. nginx proxies /ws to * the daemon's WebSocket port. @@ -126,55 +136,41 @@ function _wsConnect() { } /** - * Route an incoming WS message to model refresh and direct handlers. + * Patch models in place from a data-carrying WS message. * - * Expected message shapes: - * { type: 'versions', updated: ['firewall', 'dnsmasq', …] } - * { type: 'tick', subsystems: ['firewall', 'wireguard', …] } - * { type: 'notify', topic: 'firewall' } - * { type: 'status', topic: 'firewall', … } + * Expected message shapes (daemon → client): + * { type: 'snapshot', data: {subsystem: state|null, …} } // on connect + * { type: 'versions', subsystem: 'firewall', data: state } // structural change + * { type: 'tick', subsystem: 'system', data: state } // volatile change + * + * The daemon only sends these three types after the WS push-stream + * migration; unknown / retired types (refresh/notify/status, legacy + * versions.updated, tick.subsystems) are ignored — no backward compat. */ function handleMessage(msg) { - const topics = []; - - if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') { - topics.push(...(msg.updated || msg.subsystems || msg.topics || [])); - } else if (msg.type === 'notify') { - topics.push(msg.topic); - } else if (msg.type === 'status') { - topics.push(msg.topic || '*'); - } - - // Refresh models for each topic - for (const topic of topics) { - refreshByTopic(topic); - } - - // Notify direct onMessage handlers - for (const h of _directHandlers) { - if (h.unsubscribed) continue; - if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) { - try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); } + if (msg.type === 'snapshot') { + // Full state on connect — set all models (null = collector failed, skip) + for (const [subsystem, data] of Object.entries(msg.data)) { + if (data !== null) { + const modelName = _SUBSYSTEM_TO_MODEL[subsystem] || subsystem; + modelSet(modelName, data); + } } + return; } -} -/** - * Public subscribe API for direct one-off usage (e.g. from page code). - * Handler receives the raw parsed message when a matching topic arrives. - * @param {string|string[]} topics - * @param {function} handler - * @returns {function} unsubscribe - */ -export function onMessage(topics, handler) { - const tArray = Array.isArray(topics) ? topics : [topics]; - const entry = { topics: tArray, handler, unsubscribed: false }; - _directHandlers.push(entry); - return () => { - entry.unsubscribed = true; - const idx = _directHandlers.indexOf(entry); - if (idx !== -1) _directHandlers.splice(idx, 1); - }; + if ((msg.type === 'versions' || msg.type === 'tick') + && msg.subsystem && msg.data != null) { + // Delta for one subsystem — patch the corresponding model. + // Guard is `!= null` (not `!== undefined`): a null payload means the + // collector failed — never overwrite good model data (defense in depth; + // the daemon skips null broadcasts). + const modelName = _SUBSYSTEM_TO_MODEL[msg.subsystem] || msg.subsystem; + modelSet(modelName, msg.data); + return; + } + + // Everything else is unknown / retired — ignored. } /** Start the WebSocket connection. */ diff --git a/webui/static/pages/backends.js b/webui/static/pages/backends.js index 2245646..542f3ea 100644 --- a/webui/static/pages/backends.js +++ b/webui/static/pages/backends.js @@ -239,8 +239,8 @@ export function openBackendModal(state, backend) { if (res.ok) { toast(isEdit ? 'Backend updated' : 'Backend added', 'success'); closeModal(); - await modelFetch('backends'); - modelFetch('nginx'); + await modelFetch('backends'); // not state-store-backed — explicit fetch + // No nginx modelFetch — daemon broadcasts nginx via WS delta. } else { toast(res.error || 'Failed', 'error'); } @@ -290,7 +290,7 @@ export default definePage({ deleteKey=${name} message=${'Remove backend ' + enc(name) + '?'} success="Backend removed" - refresh=["backends", "nginx"] + onComplete=${() => modelFetch('backends')} label="Delete" />` } @@ -303,7 +303,7 @@ export default definePage({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', - refresh: ['backends', 'nginx'], + onSuccess: () => modelFetch('backends'), }), ); diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index be89322..e740d03 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js'; +import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js'; import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js'; function _accountCard(account) { @@ -56,7 +56,7 @@ function registerAccountModal() { if (!resp.ok) throw resp.error || 'Registration failed'; toast('ACME account registered', 'success'); closeModal(); - modelFetch('acme'); + // No modelFetch — WS delta updates the acme model. }), }, ], @@ -100,7 +100,7 @@ function settingsModal(account) { if (!resp.ok) throw resp.error || 'Failed'; toast('Email updated', 'success'); closeModal(idx); - modelFetch('acme'); + // No modelFetch — WS delta updates the acme model. }), ); @@ -111,7 +111,7 @@ function settingsModal(account) { if (!resp.ok) throw resp.error || 'Failed'; toast('Account deactivated', 'success'); closeModal(idx); - modelFetch('acme'); + // No modelFetch — WS delta updates the acme model. }), ); }); @@ -262,7 +262,7 @@ async function pollCertIssue(rid) { onErrorKey: (d) => d.status === 'failed', onComplete: (d) => { toast('Certificate issued for ' + (d.domain || rid), 'success'); - modelFetch('acme'); + // No modelFetch — WS delta updates the acme model. }, onError: (d) => { toast('Issuance failed: ' + (d?.error || 'unknown'), 'error'); @@ -299,7 +299,6 @@ export default definePage({ removeUrl=${'/api/certs/' + enc(c.domain)} removeMessage=${'Remove certificate for ' + c.domain + '?'} removeSuccess="Certificate removed" - removeRefresh="acme" deleteKey=${c.domain} /> `; }); diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index c22e908..7adeb23 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -9,13 +9,13 @@ export default definePage({ wireguard: getModel('wireguard'), acme: getModel('acme'), nginx: getModel('nginx'), - status: getModel('status'), + system: getModel('system'), }; }, render(state) { const guard = renderGuardMulti('Dashboard', 'System overview', state.firewall, state.network, state.dnsmasq, state.wireguard, - state.acme, state.nginx, state.status); + state.acme, state.nginx, state.system); if (guard) return guard; // Extract data @@ -33,19 +33,22 @@ export default definePage({ const allCerts = state.acme.data?.certs || []; const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30)); - // System metrics from status model - const sysMetrics = state.status.data?.metrics || {}; - const sysLoad = sysMetrics.load || {}; - const sysMem = sysMetrics.memory || {}; - const sysSwap = sysMetrics.swap || {}; - const sysTraffic = sysMetrics.traffic || {}; + // System metrics from the system model + const sysLoad = state.system.data?.load || {}; + const sysMem = state.system.data?.memory || {}; + const sysSwap = state.system.data?.swap || {}; + const sysTraffic = state.system.data?.traffic || {}; - // Pending changes - const pend = state.status.data?.pending || {}; - const totalChanges = pend.total_changes || 0; - const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => - k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes) - ); + // Pending changes — derived from the config-backed subsystem models. + // Firewall uses pending.needs_apply (config_pending() output); all + // others use status.pending_changes. `system` is metrics-only. + const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => { + const model = getModel(k === 'networkd' ? 'network' : k); + const d = model.data || {}; + if (k === 'firewall') return !!d.pending?.needs_apply; + return !!d.status?.pending_changes; + }); + const totalChanges = pendKeys.length; const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' }; // Build merged interface list @@ -53,13 +56,10 @@ export default definePage({ const ifaces = allNames.map(name => { const fw = fwIfaces.find(f => f.name === 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 ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : []; - const addrs = runtime.addresses || []; - const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s)); + const addrs = netEntry.addresses || []; + const isUp = ['routable', 'degraded', 'carrier'].some(s => (netEntry.state || '').startsWith(s)); return { name, mac: fw?.mac || null, @@ -91,7 +91,7 @@ export default definePage({

Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}

<${ActionButton} url="/api/status/apply-all" label="Apply All Changes" - successMsg="All changes applied" refresh="status" + successMsg="All changes applied" cls="btn btn-sm btn-primary" />
` diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 64d57b9..085d119 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -1,4 +1,4 @@ -import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js'; +import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js'; function makeAddRange(activeZones, interfaces) { const opts = [ @@ -70,7 +70,6 @@ function makeAddRange(activeZones, interfaces) { validate: (b) => !b.start || !b.end ? 'Start and end are required' : null, successMsg: 'Range added', }, - refresh: 'dnsmasq', }); } @@ -91,7 +90,6 @@ const addLease = QuickModal({ validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null, successMsg: 'Lease added', }, - refresh: 'dnsmasq', }); const addDns = QuickModal({ @@ -106,7 +104,6 @@ const addDns = QuickModal({ validate: (b) => !b.name || !b.address ? 'Name and address are required' : null, successMsg: 'DNS record added', }, - refresh: 'dnsmasq', }); export default definePage({ @@ -140,8 +137,7 @@ export default definePage({ deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end} message=${'Remove range ' + r.start + ' - ' + r.end + '?'} body=${{ interface: r.interface || '', start: r.start, end: r.end }} - success="Range removed" - refresh="dnsmasq" /> + success="Range removed" /> `); @@ -154,8 +150,7 @@ export default definePage({ url=${'/api/dhcp/static-lease/' + enc(l.mac)} deleteKey=${l.mac} message=${'Remove lease ' + l.mac + '?'} - success="Lease removed" - refresh="dnsmasq" /> + success="Lease removed" /> `); @@ -167,8 +162,7 @@ export default definePage({ url=${'/api/dhcp/dns-record/' + enc(rec.name || '')} deleteKey=${rec.name || 'unnamed'} message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'} - success="Record removed" - refresh="dnsmasq" /> + success="Record removed" /> `); @@ -179,7 +173,7 @@ export default definePage({ }); if (res.ok) { toast('DNS domain updated', 'success'); - modelFetch('dnsmasq'); + // No modelFetch — WS delta updates the dnsmasq model. } else { toast(res.error || 'Failed to update', 'error'); } @@ -198,7 +192,7 @@ export default definePage({ const tabNames = ['ranges', 'leases', 'dns', 'active']; const actions = ActionGroup( - html``, + html``, html``, html``, (() => { @@ -213,10 +207,9 @@ export default definePage({ let msg = 'dnsmasq applied'; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); } toast(msg, 'success'); - modelFetch('dnsmasq'); + // No modelFetch — WS delta updates the dnsmasq model. } else { toast(res.error || 'Apply failed', 'error'); } diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index 255ef6e..29b4a27 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js'; +import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js'; async function changeZone(name, zone, state) { const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { @@ -7,8 +7,7 @@ async function changeZone(name, zone, state) { }); if (r.ok) { toast(name + ' \u2192 ' + zone, 'success'); - modelFetch('firewall'); - modelFetch('network'); + // No modelFetch — daemon broadcasts both subsystems via WS delta. } else { toast(r.error || 'Failed', 'error'); } @@ -33,7 +32,6 @@ const cfgModalFn = QuickModal({ }), successMsg: 'Config saved', }, - refresh: ['firewall', 'network'], }); export default definePage({ @@ -49,8 +47,11 @@ export default definePage({ const fwZones = state.firewall.data?.zones || {}; const netData = state.network.data?.interfaces || {}; - const zones = fwZones.available || []; - const activeZones = fwZones.active || {}; + const zones = Object.keys(fwZones); + const activeZones = state.firewall.data?.active_zones || {}; + // Per-interface config lives in the top-level config (flat runtime + // entries carry no per-interface config). + const netCfgIfaces = state.network.data?.config?.interfaces || {}; // Loopback has no networkd config to manage — show real NICs only. const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => { @@ -63,11 +64,11 @@ export default definePage({ } return { name, - mac: entry?.runtime?.mac || null, - ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])], - state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down', + mac: entry?.mac || null, + ips: [...(netCfgIfaces[name]?.addresses || []), ...(entry?.addresses || [])], + state: (entry?.state || '').startsWith('routable') || (entry?.state || '').startsWith('carrier') ? 'up' : 'down', zone, - config: entry?.config || {}, + config: netCfgIfaces[name] || {}, }; }); diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index 709799b..a8e1196 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js'; const addFwd = QuickModal({ title: 'Add Port Forward', @@ -21,7 +21,6 @@ const addFwd = QuickModal({ validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null, successMsg: 'Forward rule added', }, - refresh: 'firewall', }); export default definePage({ @@ -37,7 +36,7 @@ export default definePage({ const cfg = state.firewall.data?.config || {}; const zoneData = cfg.zones || {}; - const sIface = (state.firewall.data?.state || {}).interfaces || []; + const sIface = state.firewall.data?.interfaces || []; // With nftables, masquerade is propagated to the public zone at runtime for // POSTROUTING to work. The config-side masquerade flag indicates which // zones source NAT traffic (LAN / internal), not where traffic exits (WAN). @@ -92,8 +91,7 @@ export default definePage({ cls="btn btn-sm btn-outline" labelOn="Disable" labelOff="Enable" condition=${masq} body=${() => ({ zone, enable: !masq })} - successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} - refresh="firewall" /> + successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} /> `; }); @@ -115,8 +113,7 @@ export default definePage({ url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)} deleteKey=${zone + '/' + port + '/' + proto} message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'} - success="Rule removed" - refresh="firewall" /> + success="Rule removed" /> `); }); diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index be1ffd0..e1c47ca 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -104,7 +104,6 @@ function addDomain(state, preselectedBackend) { !b.backend ? 'Backend is required' : null, successMsg: 'Domain added', }, - refresh: ['nginx', 'acme'], postRender: (inner) => { if (preselectedBackend) { const backendSelect = inner.querySelector('#p-backend'); @@ -161,7 +160,6 @@ function editDomain(d, state) { validate: (b) => !b.cert ? 'Cert is required' : null, successMsg: 'Domain updated', }, - refresh: ['nginx', 'acme'], postRender: (inner) => { const certSelect = inner.querySelector('#pe-cert'); if (certSelect) certSelect.value = selectedCert; @@ -213,7 +211,6 @@ function domainRow(domainName, domainPaths, state) { removeUrl=${'/api/proxy/domains/' + enc(domainName)} removeMessage=${'Remove ' + enc(domainName) + '?'} removeSuccess="Domain removed" - removeRefresh=["nginx", "acme"] removeLabel="Delete" /> `; @@ -231,7 +228,7 @@ function backendSection(section, state) { deleteKey=${backendName} message=${'Remove backend ' + enc(backendName) + '?'} success="Backend removed" - refresh=["backends", "nginx"] + onComplete=${() => modelFetch('backends')} label="Delete" />`); } } @@ -267,7 +264,7 @@ export default definePage({ const sectionVNodes = sections.map(s => backendSection(s, state)); const actions = ActionGroup( html``, - ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }), + ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }), ); return [ PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js index 9914de7..a3e93d6 100644 --- a/webui/static/pages/rules.js +++ b/webui/static/pages/rules.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js'; +import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js'; const addRule = QuickModal({ title: 'Add Rich Rule', @@ -12,7 +12,6 @@ const addRule = QuickModal({ validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null, successMsg: 'Rule added', }, - refresh: 'firewall', }); export default definePage({ @@ -26,7 +25,7 @@ export default definePage({ if (guard) return guard; const cfg = state.firewall.data?.config || {}; - const zones = state.firewall.data?.zones?.available || []; + const zones = Object.keys(state.firewall.data?.zones || {}); const zoneData = cfg.zones || {}; const zoneRules = {}; Object.entries(zoneData).forEach(([zname, zcfg]) => { @@ -46,8 +45,7 @@ export default definePage({ url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')} deleteKey=${zone + '-' + (ruleId || i)} message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'} - success="Rule removed" - refresh="firewall" /> + success="Rule removed" /> `; }); diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 9643080..182b806 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -1,5 +1,5 @@ /** WireGuard page — tunnel & peer management. */ -import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js'; /* ── LAN detection helper ────────────────────────────────────── */ function getLanSubnets() { @@ -76,7 +76,6 @@ const addPeer = QuickModal({ !b.access_class ? 'Access Class is required' : null, successMsg: 'Peer added', }, - refresh: 'wireguard', postRender: (inner, data) => { const presetEl = document.getElementById('wg-allowed-preset'); if (presetEl) { @@ -256,7 +255,7 @@ function settingsModal(wireguardData, state) { if (!resp.ok) throw resp.error || 'Failed to save'; toast('Settings saved', 'success'); closeModal(idx); - modelFetch('wireguard'); + // No modelFetch — WS delta updates the wireguard model. }), }, ], @@ -291,7 +290,6 @@ const addClass = QuickModal({ !b.listen_port ? 'Listen port is required' : null, successMsg: 'Class added', }, - refresh: 'wireguard', postRender: (inner) => { const sel = document.getElementById('wc-lan'); if (sel) { @@ -330,7 +328,7 @@ function editClassModal(key, cls, peerCount) { if (!resp.ok) throw resp.error || 'Failed'; toast('Class updated', 'success'); closeModal(idx); - modelFetch('wireguard'); + // No modelFetch — WS delta updates the wireguard model. }), }, ], @@ -374,17 +372,17 @@ function renderAccessClasses(config, status) { ${!hasKeys ? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys" - cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} refresh="wireguard" />` + cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} />` : ''} <${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')} cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp} - successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} refresh="wireguard" /> + successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} /> ${(pCount > 0) ? html`` : html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }} deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted" - refresh="wireguard" label="Delete" />`} + label="Delete" />`} `; }); @@ -469,7 +467,6 @@ export default definePage({ removeUrl=${'/api/wireguard/peers/' + enc(p.name)} removeMessage=${'Remove peer ' + p.name + '?'} removeSuccess="Peer removed" - removeRefresh="wireguard" deleteKey=${p.name} /> `; }); @@ -492,12 +489,12 @@ export default definePage({
Subnet: ${esc(v.subnet || '-')} LAN: ${v.lan_access ? 'Yes' : 'No'} - Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} refresh="wireguard" />`} + Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} />`}
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')} cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp} - successMsg=${isUp ? 'Stopped' : 'Started'} refresh="wireguard" /> + successMsg=${isUp ? 'Stopped' : 'Started'} />
`; @@ -512,12 +509,10 @@ export default definePage({ url: '/api/wireguard/' + (isUp ? 'down' : 'up'), labelOn: 'Stop All', labelOff: 'Start All', condition: isUp, successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'), - refresh: 'wireguard', }), ApplyConfirm({ pending: st.pending_changes || false, successMsg: 'WireGuard applied', - refresh: ['wireguard', 'firewall'], }), ); diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index aeec2f1..4070ae8 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -12,7 +12,6 @@ const addZone = QuickModal({ validate: (b) => !b.name ? 'Zone name required' : null, successMsg: 'Zone created', }, - refresh: 'firewall', }); export default definePage({ @@ -25,8 +24,8 @@ export default definePage({ const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones); if (guard) return guard; - const zones = state.firewall.data?.zones?.available || []; - const activeZones = state.firewall.data?.zones?.active || {}; + const zones = Object.keys(state.firewall.data?.zones || {}); + const activeZones = state.firewall.data?.active_zones || {}; const zoneDetails = {}; for (const name of zones) { const activeIfaces = activeZones[name]; @@ -67,24 +66,32 @@ export default definePage({ selected: ifacesArr, fieldKey: 'interfaces', successMsg: 'Interfaces updated', - refresh: 'firewall', })()}>Interfaces <${ConfirmDelete} url=${'/api/firewall/zones/' + enc(name)} deleteKey=${name} message=${'Delete zone ' + name + '?'} success=${'Zone ' + name + ' deleted'} - refresh="firewall" label="Delete" /> `;