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
This commit is contained in:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+15 -4
View File
@@ -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 <cmd> ──→ system service
```
@@ -16,6 +16,15 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ 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 <token>`; 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
+75 -7
View File
@@ -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})
)
+84 -37
View File
@@ -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:
+49 -7
View File
@@ -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 <token>` 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.
| 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.
+12 -9
View File
@@ -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 <token> 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=<access_token>)
Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer <token>` 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/<path>` 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
+4
View File
@@ -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/<name>/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/<name>/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`
+2 -2
View File
@@ -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://<management-domain>` — should show a login page
### Certificate Note
+146 -83
View File
@@ -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 |
@@ -28,10 +28,10 @@ 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') / ...
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle
└── 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',
// 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/firewall/config');
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: [subsystem] },
});
if (!r.ok) throw new Error(r.error);
return r.data;
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`<div class="card">
```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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades.
## Communication Between WebUI and Daemon
+174
View File
@@ -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(<subsystem>)` 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-<class>
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.
+485
View File
@@ -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-<class> 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-<class> 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/<iface>/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
+34 -8
View File
@@ -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__ = [
+111
View File
@@ -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;
})();
+175
View File
@@ -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;
})();
+220
View File
@@ -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 daemonclient messages, then assert the
* reactive model state. Covers the snapshot fast path, per-subsystem deltas
* (including the networkdnetwork 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;
})();
+57
View File
@@ -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
@@ -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)
+146 -2
View File
@@ -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):
+17 -9
View File
@@ -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:
+144
View File
@@ -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)
+147
View File
@@ -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: <LOOPBACK,UP> mtu 65536\n"
"2: eth0: <BROADCAST,UP> 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
+25
View File
@@ -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]:
+114
View File
@@ -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)
+127
View File
@@ -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()
+24 -2
View File
@@ -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.
+48 -113
View File
@@ -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;
},
/* ── 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] },
});
modelRegister('network', {
subsystem: 'networkd',
fetch: async () => {
const r = await apiFetch('/api/network/interfaces');
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']) {
// 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');
}
+13 -7
View File
@@ -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');
}
+8 -11
View File
@@ -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`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
}
/**
* POST apply-all, toast result, close modal, refresh models.
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*/
async function doApply(successMsg, refreshTargets) {
async function doApply(successMsg) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
@@ -66,10 +66,7 @@ async function doApply(successMsg, refreshTargets) {
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
}
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(resp.error || 'Apply failed', 'error');
}
@@ -82,7 +79,7 @@ async function doApply(successMsg, refreshTargets) {
/**
* Fetch pending state, then open the confirmation modal.
*/
async function openApplyModal(successMsg, refreshTargets) {
async function openApplyModal(successMsg) {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
@@ -109,7 +106,7 @@ async function openApplyModal(successMsg, refreshTargets) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, refreshTargets)}">Apply All</button></div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
</div>`);
});
}
@@ -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);
}
+21 -35
View File
@@ -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();
}
// 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')
+4 -2
View File
@@ -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
+2 -2
View File
@@ -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';
+17
View File
@@ -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.
+80
View File
@@ -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)
};
+45 -49
View File
@@ -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 || '*');
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;
}
// Refresh models for each topic
for (const topic of topics) {
refreshByTopic(topic);
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;
}
// 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); }
}
}
}
/**
* 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);
};
// Everything else is unknown / retired — ignored.
}
/** Start the WebSocket connection. */
+4 -4
View File
@@ -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" />`
}
</td>
@@ -303,7 +303,7 @@ export default definePage({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['backends', 'nginx'],
onSuccess: () => modelFetch('backends'),
}),
);
+5 -6
View File
@@ -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} />
</tr>`;
});
+20 -20
View File
@@ -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({
<div class="card-body">
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
<${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" />
</div>
</div>`
+7 -14
View File
@@ -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" />
</td>
</tr>`);
@@ -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" />
</td>
</tr>`);
@@ -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" />
</td>
</tr>`);
@@ -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`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.active_zones, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
(() => {
@@ -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');
}
+11 -10
View File
@@ -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] || {},
};
});
+4 -7
View File
@@ -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} />
</td>
</tr>`;
});
@@ -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" />
</td>
</tr>`);
});
+2 -5
View File
@@ -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" />
</td>
</tr>`;
@@ -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`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
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 }),
+3 -5
View File
@@ -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" />
</td>
</tr>`;
});
+8 -13
View File
@@ -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) {
<td>
${!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)} />`
: ''}
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
<${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`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
: 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" />`}
</td>
</tr>`;
});
@@ -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} />
</tr>`;
});
@@ -492,12 +489,12 @@ export default definePage({
<div class="d-flex justify-content-between">
<span>Subnet: ${esc(v.subnet || '-')}</span>
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
<span>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" />`}</span>
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} />`}</span>
</div>
<div style="margin-top: 4px;">
<${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'} />
</div>
</div>
</div>`;
@@ -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'],
}),
);
+14 -7
View File
@@ -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</button>
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.services || [],
options: state.firewall.data?.available_services || [],
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
refresh: 'firewall',
confirm: (b) => {
const svcs = (b && b.services) || [];
const isDefault = name === state.firewall.data?.default_zone;
if (isDefault && !svcs.includes('https') && !svcs.includes('ssh')) {
return 'This removes both HTTPS and SSH from the default zone ' +
"'" + name + "'. Management access and remote recovery " +
'through this zone will be blocked until you reach the ' +
'appliance via console or another route.\n\nRemove them anyway?';
}
return null;
},
})()}>Services</button>
<${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)}
deleteKey=${name}
message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'}
refresh="firewall"
label="Delete" />
</div>
</div>`;