diff --git a/AGENTS.md b/AGENTS.md index 44b980a..50f0be0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the | `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` | | `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — | | `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — | +| `webui/api/auth` | `/api/auth/` | `daemon/handlers/auth` | `lib.auth` / `lib.auth_users` | ## Privileged Operations @@ -160,7 +161,7 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h **Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`. -**Tests:** pytest in `tests/` (27 test files). All subprocess calls are mocked — no system services required. +**Tests:** pytest in `tests/` (28 Python + 9 JS test files). All subprocess calls are mocked — no system services required. ```bash .venv/bin/ruff check lib/ webui/ tests/ # lint diff --git a/README.md b/README.md index 655e4e5..5e46b72 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire - Debian 13 (trixie) target platform - Python 3.13+, Flask 3.x web UI - firewalld (nftables backend), dnsmasq, nginx, WireGuard -- acme.sh for ACME certificates (ZeroSSL) +- acme.sh for ACME certificates (CA is config-driven; code default Let's Encrypt) --- @@ -42,9 +42,9 @@ bash scripts/install.sh | Flag | Env Var | Required | Description | |---|---|---|---| | -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) | -| `--mgmt-pass` | `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI | +| `--mgmt-pass` | `MGMT_PASS` | Yes | SQLite DB password for the initial `admin` user (full `rw` on all subsystems) — not an nginx htpasswd | | `--mgmt-user` | `MGMT_USER` | No | WebUI username (defaults to `admin`) | -| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) | +| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (CA is config-driven; code default Let's Encrypt) | | `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) | | `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) | | `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning | @@ -99,7 +99,7 @@ All `lib/` modules share `lib.common` utilities (`run`, `run_proc`, `load_json`, .venv/bin/python -m pytest tests/ -v ``` -Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 192 tests across 5 test modules. +Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 28 Python test files (pytest) + 9 JS test files (jsdom/node harness). ### Documentation MCP Server @@ -115,17 +115,23 @@ Then run with Claude Code or Opencode to activate it. It automatically checks li ### Architecture ``` -Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090) -Flask ──→ lib/*.py ──→ sudo ──→ system service +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 ──→ handlers ──→ sudo ``` -| Blueprint | URL prefix | Backend module | -|---|---|---| -| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` | -| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` | -| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` | -| `webui/api/certs` | `/api/certs/` | `lib.acme` | -| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` | +Blueprints are thin proxies — privileged handlers live in `daemon/handlers/*.py`. + +| Blueprint | URL prefix | Handler | lib module | +|---|---|---|---| +| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` | +| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` | +| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` | +| `webui/api/certs` | `/api/certs/` | `daemon/handlers/acme` | `lib.acme` | +| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard` | `lib.wireguard` | +| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` | +| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — | +| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — | +| `webui/api/auth` | `/api/auth/` | `daemon/handlers/auth` | `lib.auth` / `lib.auth_users` | See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone model, and shared utility patterns. @@ -139,3 +145,5 @@ See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone - [API Reference](docs/api.md) — REST API endpoints - [Security Model](docs/security.md) — Privilege model and sudo whitelist - [Configuration](docs/config.md) — Declarative config file formats and locations +- [State Model](docs/state-model.md) — Per-subsystem state schema and real-time push mechanics +- [Frontend (hoover)](docs/hoover.md) — Custom reactive SPA framework API reference diff --git a/docs/api.md b/docs/api.md index 48a98fe..4ca7d57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # REST API Reference -All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer ` header. Public endpoints (login, WebAuthn authenticate) do not require a token. +All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer ` header. Public endpoints — `POST /api/auth/login`, `POST /api/auth/refresh`, and the WebAuthn authenticate endpoints — do not require a token. Every other request must send both the `Authorization: Bearer ` header and the mandatory `X-Session-Id` header (a missing/invalid token **or** a missing `X-Session-Id` yields HTTP `401`). Every request and response uses `Content-Type: application/json`. @@ -17,7 +17,7 @@ Most endpoints require a valid JWT access token. The token is obtained by loggin ### Permission Checks -Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`. +Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. A request with no permission entry for its subsystem (or a method/level mismatch) is rejected with HTTP `403`. User management endpoints (`/api/auth/users/*`) and credential counts (`/api/auth/webauthn/credential-counts`) follow the same rule: `GET` needs only `auth: "read"`, while `POST`/`PATCH`/`DELETE` need `auth: "rw"`. ## Conventions @@ -46,6 +46,8 @@ Error responses carry one of the following HTTP status codes: | Code | Meaning | |------|---------| | `400` | Bad request — invalid body, missing required field, or malformed value | +| `401` | Unauthorized — missing/invalid `Bearer` token, missing `X-Session-Id` header, or invalid/expired/blacklisted token | +| `403` | Forbidden — the caller lacks the required subsystem permission (`auth: "rw"` where needed, or no entry for the subsystem) | | `404` | Not found — the requested resource does not exist | | `409` | Conflict — the requested operation conflicts with an existing resource | | `500` | Internal server error — unexpected failure in the backend | @@ -123,7 +125,7 @@ Invalidate the current session by blacklisting the access token. **Auth:** Access token required. -**Response:** `data` is `null` on success. +**Response:** `data` is `{}` (an empty object) on success. #### Refresh Tokens @@ -131,9 +133,16 @@ Invalidate the current session by blacklisting the access token. POST /api/auth/refresh ``` -Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens. +Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens. The request body must carry **both** `refresh_token` and `session_id` (session binding). -**Auth:** Refresh token required. +**Auth:** Public — no JWT required (this is a public endpoint, so the `X-Session-Id` header is not sent). + +**Request Body:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `refresh_token` | `string` | Yes | The refresh token to rotate | +| `session_id` | `string` | Yes | Session ID from the token pair (session binding) | **Response (`data`):** @@ -162,11 +171,11 @@ Change the current user's password. |---|---|---|---| | `username` | `string` | No | Auto-injected from JWT context | | `oldPassword` | `string` | Yes | Current password | -| `newPassword` | `string` | Yes | New password | +| `newPassword` | `string` | Yes | New password (minimum 8 characters) | -**Response:** `data` is `null` on success. +**Response:** `data` is `{"ok": true}` on success. -Returns HTTP `400` if old password is incorrect. +Returns HTTP `400` for any failure — missing fields, incorrect old password, or a new password shorter than 8 characters. --- @@ -178,15 +187,11 @@ Returns HTTP `400` if old password is incorrect. GET /api/auth/users ``` -List all users. Requires admin permission (`auth: "rw"`). +List all users. -**Auth:** `auth: "rw"` required. +**Auth:** `auth: "read"` required (read-only endpoint). -**Response (`data`):** - -| Field | Type | Description | -|---|---|---| -| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) | +**Response (`data`):** the array of user summaries directly (no `users` wrapper). Each entry has `id`, `username`, `permissions` (`{ subsystem: "read" | "rw" }`), and `created_at`. #### Create User @@ -203,7 +208,7 @@ Create a new user with password and per-subsystem permissions. | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username | -| `password` | `string` | Yes | Plain-text password | +| `password` | `string` | Yes | Plain-text password (minimum 8 characters) | | `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | **Response (`data`):** @@ -214,7 +219,7 @@ Create a new user with password and per-subsystem permissions. | `username` | `string` | Username | | `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | -Returns HTTP `409` if username already exists. +Returns HTTP `409` if the username already exists. Returns HTTP `400` if the password is missing or shorter than 8 characters. #### Update User @@ -254,12 +259,37 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE). **Response:** `data` is `{"ok": true}` on success. -Returns HTTP `404` if user not found. +Returns HTTP `403` if the user attempts to delete their own account. Returns HTTP `404` if the user is not found. --- ### WebAuthn +#### Check WebAuthn Capability + +``` +GET /api/auth/webauthn/capable +``` + +Check whether WebAuthn is available on the current request domain (the relying-party ID is derived from the request host). + +**Auth:** Access token required. + +**Response (`data`):** + +When enabled: + +| Field | Type | Description | +|---|---|---| +| `enabled` | `boolean` | Always `true` | +| `rp_id` | `string` | Relying-party ID (request host) | +| `rp_name` | `string` | Relying-party display name | +| `origin` | `string` | Resolved WebAuthn origin (`scheme://host`) | + +When unavailable: `{"enabled": false, "reason": ""}`. + +--- + #### Begin Registration ``` @@ -274,9 +304,9 @@ Start WebAuthn credential registration. Returns options for `navigator.credentia | Field | Type | Required | Description | |---|---|---|---| -| `username` | `string` | Yes | Username to register for | +| `username` | `string` | No | Auto-injected from the JWT (the authenticated user); any value in the body is overridden | -**Response (`data`):** +**Response (`data`):** Standard WebAuthn registration options. | Field | Type | Description | |---|---|---| @@ -299,13 +329,19 @@ Complete WebAuthn credential registration. Verifies the attestation response and | Field | Type | Required | Description | |---|---|---|---| -| `username` | `string` | Yes | Username | -| `response` | `object` | Yes | WebAuthn authenticator attestation response | +| `username` | `string` | No | Auto-injected from the JWT; any value in the body is overridden | +| `credential_response` | `object` | Yes | WebAuthn authenticator attestation response | +| `registration_options` | `object` | Yes | The registration options returned by `register-begin` | | `name` | `string` | No | Display name for this credential | -**Response:** `data` is `null` on success. +**Response (`data`):** -Returns HTTP `400` if verification fails. +| Field | Type | Description | +|---|---|---| +| `ok` | `boolean` | Always `true` | +| `credential` | `object` | The stored credential (`id`, `name`, `transports`, `sign_count`) | + +Returns HTTP `400` if verification fails or required fields are missing. #### Begin Authentication @@ -359,7 +395,7 @@ Complete WebAuthn authentication. Verifies the assertion and issues tokens on su | `user` | `object` | User info (`username`, `id`) | | `permissions` | `object` | Per-subsystem permissions | -Returns HTTP `400` if verification fails. +Returns HTTP `401` if verification fails or required fields are missing. #### List Credentials @@ -373,7 +409,7 @@ List WebAuthn credentials for the current user. **Response (`data`):** -Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`). +Array of credential objects (`id`, `name`, `transports`, `sign_count`). #### Credential Counts @@ -381,15 +417,11 @@ Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCo GET /api/auth/webauthn/credential-counts ``` -Return credential counts for all users. Admin endpoint. +Return credential counts for all users. -**Auth:** `auth: "rw"` required. +**Auth:** `auth: "read"` required (read-only endpoint). -**Response (`data`):** - -| Field | Type | Description | -|---|---|---| -| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) | +**Response (`data`):** The dict directly (no `counts` wrapper) — a mapping of usernames to credential counts (`{"alice": 2, "bob": 1}`). #### Remove Credential @@ -401,9 +433,9 @@ Remove a WebAuthn credential. **Auth:** Access token required. -**Response:** `data` is `null` on success. +**Response:** `data` is `{"ok": true}` on success. -Returns HTTP `404` if credential not found. +Returns HTTP `404` if the credential is not found. --- @@ -454,7 +486,7 @@ POST /api/firewall/config/apply Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports. -**Request Body:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards. +**Request Body:** None. The webui route accepts no body — the `{"force": true}` override of the management-lockout and interface-coverage guards is a **daemon-only** capability and cannot be sent through this webui endpoint. (To force an apply through the webui, use `POST /api/status/apply-all` with `{"force": true}`, which forwards `force` to the firewall apply.) **Errors:** Returns HTTP `409` when the apply is refused by the management-lockout guard (https+ssh stripped from the default zone) or the interface-coverage invariant (a network-managed interface has no zone coverage and is not `unmanaged`). See `docs/config.md`. @@ -472,9 +504,18 @@ Apply the declarative config to live firewalld. Applies targets, services, inter GET /api/firewall/config/pending ``` -Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports. +Compare declarative config against live firewalld state. Returns the diff for interfaces, services, targets, masquerade, rich rules, and forward ports. -**Response:** Same structure as POST /config response. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `pending` | `[object, ...]` | List of pending changes | +| `needs_apply` | `boolean` | Whether changes need to be applied | +| `unmanaged_zones` | `object` | Zones active on the system but not present in the config | +| `pending_summary` | `[string, ...]` | Human-readable summary string per pending change | + +Unlike the `POST`/`PATCH /config` save response, this endpoint does **not** include `config_saved`; instead it adds `pending_summary`. #### Partial Update Config @@ -528,13 +569,18 @@ Return detailed configuration for a single zone. | Field | Type | Description | |-------|------|-------------| +| `name` | `string` | Zone name | | `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) | | `interfaces` | `[string, ...]` | Interfaces assigned to this zone | +| `sources` | `[string, ...]` | Source IPs addressed by this zone | | `services` | `[string, ...]` | Services allowed through the zone | | `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) | +| `protocols` | `[string, ...]` | Protocols to accept | +| `icmp-blocks` | `[string, ...]` | ICMP types blocked | | `masquerade` | `boolean` | Whether masquerade (NAT) is enabled | -| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules | -| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs | +| `ics` | `boolean` | Whether ICMP redirect (ICS) is enabled | +| `forward-ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules (key is hyphenated) | +| `rich-rules` | `[string, ...]` | Rich rule strings (key is hyphenated) | Returns HTTP `404` if the zone does not exist. @@ -596,6 +642,8 @@ Replace all interfaces assigned to the zone with the provided list. | `zone` | `string` | Zone name | | `interfaces` | `[string, ...]` | List of interface names now assigned | +Returns HTTP `404` if the zone does not exist. + --- #### Set Zone Services @@ -611,6 +659,7 @@ Replace all services allowed in the zone with the provided list. | Field | Type | Required | Description | |-------|------|----------|-------------| | `services` | `[string, ...]` | Yes | List of firewalld service names | +| `force` | `boolean` | No | Override the management-lockout guard | **Response (`data`):** @@ -619,6 +668,8 @@ Replace all services allowed in the zone with the provided list. | `zone` | `string` | Zone name | | `services` | `[string, ...]` | List of services now allowed | +Returns HTTP `404` if the zone does not exist. Returns HTTP `409` if the change would strip both https and ssh from the default zone (the management-lockout guard) and `force` is not set. + ### Rich Rules #### Add Rich Rule @@ -671,13 +722,13 @@ Returns HTTP `404` if the rule ID is not found. GET /api/firewall/rich-rules/ ``` -Return all rich rules for the specified zone, each with an `id` and `rule` string. +Return all rich rules for the specified zone. Each entry carries a `rule` string; rules that are tracked in the declarative config also carry an `id`. **Response:** | Field | Type | Description | |-------|------|-------------| -| `data` | `[{id, rule}, ...]` | Rich rules with IDs | +| `data` | `[{id?, rule}, ...]` | Rich rules; `id` is present only for rules that have a matching config entry (live-only rules are returned without `id`) | ### Port Forwarding @@ -752,6 +803,8 @@ Toggle masquerade (source NAT) for a zone. | `zone` | `string` | Zone name | | `masquerade` | `boolean` | Whether masquerade is now enabled | +Returns HTTP `400` when attempting to enable masquerade on the `public` zone (it is not supported there — use `internal` or `vpn`). + ### State #### Get Firewall State @@ -866,7 +919,7 @@ Deep-merge the provided fields into the existing configuration. Useful for targe POST /api/dhcp/apply ``` -Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service. +Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and **restart** the dnsmasq service (`systemctl restart dnsmasq`, not a reload). **Response:** `data` is `null` on success. @@ -878,22 +931,17 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa GET /api/dhcp/status ``` -Return the current service status, config summary, and active lease count. +Return the current service status and pending-change summary. **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `service_active` | `boolean` | Whether dnsmasq is running | -| `config_file_exists` | `boolean` | Whether config file exists on disk | -| `config_in_sync` | `boolean` | Whether disk config matches expected | -| `dhcp_ranges` | `number` | Number of DHCP ranges | -| `static_leases` | `number` | Number of static leases | -| `custom_dns_records` | `number` | Number of custom DNS records | -| `upstreams` | `[string, ...]` | Upstream DNS servers | -| `domain` | `string` | Local DNS domain | +| `config_file_exists` | `boolean` | Whether the config file exists on disk | | `active_leases` | `number` | Number of active leases | -| `leases` | `[object, ...]` | Active lease objects | +| `pending_changes` | `boolean` | Whether the saved config differs from the last applied state | +| `pending_diff` | `[object, ...]` | Per-field pending changes (diff of config vs applied baseline) | ### DHCP Ranges @@ -930,12 +978,14 @@ Remove a DHCP range. Body contains identifying fields. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `interface` | `string` | Yes | Interface name | +| `interface` | `string` | No | Interface name; defaults to `""` (all interfaces) | | `start` | `string` | Yes | Start of IP range | | `end` | `string` | Yes | End of IP range | **Response:** `data` is `null` on success. +Returns HTTP `404` if no range matches the given interface/start/end. + ### Static Leases #### Add Static Lease @@ -1034,6 +1084,26 @@ Returns HTTP `404` if no matching record is found. --- +### DNS Search Domain + +#### Set Search Domain + +``` +POST /api/dhcp/domain +``` + +Set or clear the DNS search domain. Pass `domain` to set it, or `null` to clear it. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `domain` | `string` | No | DNS search domain; `null` clears it | + +**Response:** `data` is `null` on success. + +--- + ## Proxy API Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy. @@ -1122,7 +1192,7 @@ Return all configured proxy domains. The response is flattened by path — each |-------|------|-------------| | `data` | `[object, ...]` | Array of path-level domain configuration objects | -Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags. +Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `backend_name` (string — the name of the referenced backend), `cert` (string or `null`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags. --- @@ -1132,27 +1202,17 @@ Each entry contains `domain` (string), `path` (string), `backend` (object with ` POST /api/proxy/domains ``` -Add a new reverse proxy domain. Accepts two modes: +Add a new reverse proxy domain that routes to a named backend. The "paths mode" / "legacy mode" split no longer exists — domains reference a backend by name and per-path routing lives on the backend itself. -**Paths mode (preferred):** +**Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `domain` | `string` | Yes | Domain name to proxy | -| `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. | +| `backend` | `string` | Yes | Name of an existing backend (a key under `backends`) | | `cert` | `string` | No | Certificate type | | `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) | - -**Legacy mode (backward compatible):** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `domain` | `string` | Yes | Domain name to proxy | -| `backend_host` | `string` | Yes | Backend server IP or hostname | -| `backend_port` | `number` | Yes | Backend server port | -| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` | -| `cert` | `string` | No | Certificate type | -| `extra_headers` | `object` | No | Extra proxy headers | +| `auth` | `object` | No | Basic auth as `{user, pass}`; when both are present a `.htpasswd` file is written as a side-effect and the raw password is **not** persisted (only `{user, htpasswd: }` is stored) | **Response (`data`):** @@ -1160,7 +1220,7 @@ Add a new reverse proxy domain. Accepts two modes: |-------|------|-------------| | `domain` | `string` | Domain name | -Returns HTTP `400` if the domain is already configured. +Returns HTTP `400` if the domain is already configured, if `domain` or `backend` is missing, or if the referenced backend does not exist. --- @@ -1170,9 +1230,14 @@ Returns HTTP `400` if the domain is already configured. PUT /api/proxy/domains/ ``` -Update one or more fields of an existing domain entry. Only fields present in the body are modified. Supports both domain-level keys (`paths`, `force_ssl`, `cert`, `auth`) and path-level shorthand (`backend`, `headers` for the root path). +Update one or more fields of an existing domain entry. Only fields present in the body are modified. -**Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`). +**Request Body:** Any subset of (`backend`, `cert`, `force_ssl`, `auth`). + +- `backend` — re-point the domain at a different existing backend name. +- `cert` — set a new certificate type, or `null` to remove it. +- `force_ssl` — toggle the HTTPS redirect flag. +- `auth` — set basic auth (see Add Domain for the `.htpasswd` side-effect), or `null` to remove it. **Response (`data`):** @@ -1180,7 +1245,7 @@ Update one or more fields of an existing domain entry. Only fields present in th |-------|------|-------------| | `domain` | `string` | Domain name | -Returns HTTP `404` if the domain is not configured. +Returns HTTP `404` if the domain is not configured. Returns HTTP `400` if the body is empty or the new `backend` does not exist. --- @@ -1200,6 +1265,86 @@ Remove a proxy domain and its nginx configuration. Returns HTTP `404` if the domain is not configured. +### Backend Management + +Backends define the per-path routing (`paths`) and any basic auth; proxy domains reference a backend by name. + +#### List All Backends + +``` +GET /api/proxy/backends +``` + +Return all configured backends. Secret material is stripped — each backend carries a `has_auth` boolean instead of its `auth` object. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `data` | `object` | Map of backend name to `{label, paths, has_auth, builtin?}` (auth stripped) | + +--- + +#### Update Backend + +``` +PATCH /api/proxy/backends +``` + +Deep-merge a partial update into an existing backend entry. Built-in backends cannot be modified. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Backend name to update | +| `label` | `string` | No | New display label | +| `paths` | `object` | No | New path-to-backend map | +| `auth` | `object` \| `false` \| `null` | No | Set basic auth, or `false`/`null` to remove it | + +**Response (`data`):** `{"backend": ""}`. + +Returns HTTP `400` if `name` is missing or the backend is built-in. Returns HTTP `500` if the backend name does not exist. + +--- + +#### Add Backend + +``` +POST /api/proxy/backends +``` + +Add a new backend. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Backend name (must be unique) | +| `label` | `string` | Yes | Display label | +| `paths` | `object` | Yes | Path-to-backend map; each entry must carry `host`, `port`, `proto` | +| `auth` | `object` | No | Basic auth configuration | + +**Response (`data`):** `{"backend": ""}`. + +Returns HTTP `400` if `name`, `label`, or `paths` is missing, if the backend already exists, or if the `paths` schema is invalid. + +--- + +#### Remove Backend + +``` +DELETE /api/proxy/backends/ +``` + +Remove a non-builtin backend. + +**Response (`data`):** `{"backend": ""}`. + +Returns HTTP `409` if one or more domains reference the backend. Returns HTTP `400` if the backend is built-in. + +--- + ### Apply / Test #### Apply Configuration @@ -1259,7 +1404,7 @@ Return all managed certificates with metadata. |-------|------|-------------| | `data` | `[object, ...]` | Array of certificate objects | -Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`. +Each certificate object contains `domain`, `issuer` (the CA/issuer name), `san_domains` (array of subject-alternative names), `expires_at`, `days_until_expiry`, `cert_path`, `key_path`, `ca_path`, and `auto_renew`. --- @@ -1269,11 +1414,13 @@ Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `c GET /api/certs/ ``` -Return details for a single certificate. +Return details for a single certificate. Matches on the main domain **or** any of the certificate's `san_domains`. -**Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`. +**Response (`data`):** The full certificate object (`domain`, `issuer`, `san_domains`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`, `ca_path`, `auto_renew`). When an issuance is currently running for the domain, an additional `issuance` field (the issuance status object) is embedded. -Returns HTTP `404` if no certificate is found for the domain. +If no certificate exists yet but an issuance is in progress, the response is `{"domain": , "status": "issuing", "issuance": {...}}`. + +Returns HTTP `404` if no certificate is found and no issuance is in progress. ### Validation @@ -1318,6 +1465,8 @@ Create a new certificate issuance request. Issuance runs asynchronously in the b | Field | Type | Description | |-------|------|-------------| | `request_id` | `string` | Unique identifier for polling issuance status | +| `domain` | `string` | Domain being issued | +| `status` | `string` | Only present when an issuance for this domain is already running — `"existing"` (the existing `request_id` is returned) | Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline). @@ -1347,7 +1496,11 @@ Start an async certificate renewal for an existing certificate. The renewal runs in the background and is polled via `GET /api/certs/renew/`. -**Request Body:** none (domain is taken from the path). +**Request Body:** Optional. The domain is taken from the path. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `force` | `boolean` | No | Force renewal even if the certificate's renewal window has not been reached (default `false`) | **Response (`data`):** @@ -1392,11 +1545,11 @@ is skipped, or fails. DELETE /api/certs/ ``` -Delete a certificate and remove it from auto-renewal tracking. +Delete a certificate and remove it from auto-renewal tracking. There is **no** existence check — the certificate may or may not exist. **Response:** `data` is `null` on success. -Returns HTTP `404` if the certificate is not found. +Returns HTTP `400` if the domain is missing. Failures (e.g. `acme.sh --remove` failing) surface as HTTP `500`; the endpoint never returns `404`. ### Account @@ -1453,7 +1606,7 @@ Returns HTTP `400` if the email is missing or invalid. Returns HTTP `500` if reg DELETE /api/certs/account ``` -Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`. +Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`. A failure in the `acme.sh` call is caught and logged but **does not** fail the endpoint — the config cleanup always runs. **Response (`data`):** @@ -1461,8 +1614,6 @@ Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `emai |-------|------|-------------| | `email` | `string` | Empty string indicating the account was deactivated | -Returns HTTP `500` if deactivation fails. - --- #### Set ACME Contact Email @@ -1481,13 +1632,11 @@ Set or update the ACME account contact email. **Response (`data`):** Returns the set `email` field. -#### Generate Self-Signed Certificate +#### Generate Self-Signed Certificate (daemon-only) -``` -POST /api/certs/self-signed -``` +There is **no** `POST /api/certs/self-signed` webui route. Self-signed generation is a daemon-only endpoint, `POST /acme/self-signed` (reached directly over the daemon socket, not via the WebUI). -Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `.key` already exist at `data/acme//`. +It generates a self-signed certificate for a domain and is idempotent — it skips generation if `.crt` and `.key` already exist at `data/certs/`. **Request Body:** @@ -1501,9 +1650,9 @@ Generate a self-signed certificate for a domain. Idempotent — skips if `fullch | Field | Type | Description | |-------|------|-------------| | `domain` | `string` | Domain name | -| `cert` | `string` | Path to `fullchain.cer` | -| `key` | `string` | Path to `.key` | -| `generated` | `boolean` | `true` if a new cert was created, `false` if existing cert was reused | +| `cert` | `string` | Path to `data/certs/.crt` | +| `key` | `string` | Path to `data/certs/.key` | +| `generated` | `boolean` | `true` if a new cert was created, `false` if the existing cert was reused | ## WireGuard API @@ -1593,14 +1742,9 @@ Alias for `/api/wireguard/apply` — write config and bring the tunnel up. POST /api/wireguard/down ``` -Bring down the WireGuard tunnel interface (`wg0`). +Bring down the WireGuard tunnel interface(s) (all class interfaces plus the legacy `wg0`). -**Response (`data`):** - -| Field | Type | Description | -|-------|------|-------------| -| `down` | `boolean` | Always `true` on success | -| `synced` | `[string, ...]` | Subsystems auto-synced as a result | +**Response:** `data` is `null` on success (the webui route discards the daemon payload). The daemon itself returns `{"down": true}` — it does not include a `synced` field. ### Status @@ -1619,6 +1763,7 @@ Return live tunnel state with interface metrics and per-peer connection statisti | `up` | `boolean` | Whether the tunnel interface is up | | `interface` | `object` | Interface info (listen port, public key) | | `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) | +| `classes` | `object` | Per-class runtime status keyed by class key (`{up, interface, peers}`) | --- @@ -1656,7 +1801,7 @@ Return all configured peers. Private keys are stripped. POST /api/wireguard/peers ``` -Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response. +Add a new WireGuard peer, or **upsert** an existing one — if the `name` is already configured, the provided fields update that peer in place (a key pair is only generated for genuinely new peers). Private key stripped from response. **Request Body:** @@ -1664,11 +1809,13 @@ Add a new WireGuard peer. A key pair is auto-generated. Private key stripped fro |-------|------|----------|-------------| | `name` | `string` | Yes | Peer identifier name | | `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) | -| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` | +| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `[]` | | `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) | | `preshared_key` | `string` | No | Preshared key | +| `description` | `string` | No | Peer description | +| `access_class` | `string` | No | Access class key this peer belongs to | -**Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`). +**Response (`data`):** The peer object with `public_key`, `endpoint`, `allowed_ips`, `persistent_keepalive`, `preshared_key`, `description`, `access_class` (no `private_key`). --- @@ -1741,11 +1888,11 @@ Manage VPN access classes that categorize peers by access level (e.g., full LAN GET /api/wireguard/classes ``` -Return all configured access classes. +Return all configured access classes. Private keys are stripped. **Response (`data`):** -Object keyed by class identifier, each with `name` and `description` fields. +Object keyed by class identifier, each entry carrying `name`, `description`, `subnet`, `listen_port`, `lan_access`, and `public_key` (private key omitted). #### Create Access Class @@ -1759,19 +1906,16 @@ Create a new access class. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `key` | `string` | Yes | Class identifier (alphanumeric) | +| `key` | `string` | Yes | Class identifier (lowercase alphanumeric) | | `name` | `string` | No | Display name (defaults to key) | | `description` | `string` | No | Description text | +| `subnet` | `string` | No | Class subnet (CIDR) | +| `listen_port` | `number` | No | Listen port for the class interface | +| `lan_access` | `boolean` | No | Whether peers get LAN access (default `false`) | -**Response (`data`):** +**Response (`data`):** The created class object (`name`, `description`, `subnet`, `listen_port`, `lan_access`, `public_key`) — note there is **no** `key` field in the response; the class is keyed by the request `key`. -| Field | Type | Description | -|-------|------|-------------| -| `key` | `string` | Class key | -| `name` | `string` | Display name | -| `description` | `string` | Description | - -Returns HTTP `409` if the key already exists. +Returns HTTP `400` if the `key` is missing or is not lowercase alphanumeric. Returns HTTP `409` if the key already exists. #### Update Access Class @@ -1779,7 +1923,7 @@ Returns HTTP `409` if the key already exists. PATCH /api/wireguard/classes ``` -Update an existing access class. +Update an existing access class. Only the fields present in the body are changed. **Request Body:** @@ -1788,8 +1932,11 @@ Update an existing access class. | `key` | `string` | Yes | Class identifier | | `name` | `string` | No | New display name | | `description` | `string` | No | New description | +| `subnet` | `string` | No | New subnet (CIDR) | +| `listen_port` | `number` | No | New listen port | +| `lan_access` | `boolean` | No | New LAN access flag | -**Response (`data`):** Updated class object with `key`, `name`, `description`. +**Response (`data`):** The updated class object — `key` plus `name`, `description`, `subnet`, `listen_port`, `lan_access`, and `public_key`. Returns HTTP `404` if the class is not found. @@ -1813,6 +1960,62 @@ Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers refere --- +#### Bring Class Tunnel Up + +``` +POST /api/wireguard/classes//up +``` + +Bring up a single class's tunnel interface (renders the class config and runs `wg-quick up`). + +**Response:** `data` is `null` on success. + +Returns HTTP `404` if the class does not exist. Returns HTTP `400` if the class has no assigned peers. + +--- + +#### Bring Class Tunnel Down + +``` +POST /api/wireguard/classes//down +``` + +Bring down a single class's tunnel interface. Note: the webui exposes this as `POST`, while the underlying daemon endpoint is a `DELETE` (`/wireguard/classes//down`). + +**Response:** `data` is `null` on success. + +Returns HTTP `404` if the class does not exist. + +--- + +#### Get Class Status + +``` +GET /api/wireguard/classes//status +``` + +Return live status for a single class's tunnel interface. + +**Response (`data`):** The class status object (`up`, `interface`, `peers`). + +Returns HTTP `404` if the class does not exist. + +--- + +#### Generate Class Keys + +``` +POST /api/wireguard/classes/keys/ +``` + +Generate a key pair for a class (idempotent — reports `generated: false` if keys already exist). + +**Response:** `data` is `null` on success via the webui (the webui route discards the daemon payload). The daemon itself returns `{generated, class_key, public_key}` (or `{generated: false, class_key, reason}` when keys already exist). + +Returns HTTP `404` if the class does not exist. + +--- + ## Network API Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters. @@ -1871,8 +2074,9 @@ Save network config for an interface, render the `.network` file, copy it to `/e | Field | Type | Description | |-------|------|-------------| | `name` | `string` | Interface name | -| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable | -| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | +| `applied` | `boolean` | Always `true` | + +The webui response is a fixed `{ "name": ..., "applied": true }` — it never reports `false` and carries no `synced` field (the daemon returns `applied`/`synced` internally, but the webui transform flattens it to this). Returns HTTP `400` if the interface name is invalid. @@ -1884,7 +2088,7 @@ Returns HTTP `400` if the interface name is invalid. POST /api/network/interfaces//reload ``` -Reload networkd for a single interface (runs `networkctl reload `). +Reload networkd for a single interface (runs `networkctl reconfigure `, not `networkctl reload`). **Response (`data`):** @@ -1950,7 +2154,7 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics |-------|------|-------------| | `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) | -Returns HTTP `500` if the value cannot be verified after write. +This is a read-only suggestion endpoint; it does not write anything and returns no write-verification errors. --- @@ -2082,21 +2286,42 @@ Re-collect state from the daemon, optionally filtered by subsystem. Proxies the Returns HTTP `500` if the daemon is unreachable. -### Sysctl +### System Metrics -#### Set Kernel Parameter +#### Get System Metrics ``` -POST /api/network/sysctl/set +GET /api/status/system-metrics ``` -Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back. +Return system-wide CPU load, memory, swap, and per-interface network traffic metrics, read from the daemon's pre-collected `system` state. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `load` | `object` | Load averages (`load1`, `load5`, `load15`) | +| `memory` | `object` | Memory usage (`total`, `available`, `used`, `used_pct`) | +| `swap` | `object` | Swap usage (`total`, `used`, `used_pct`) | +| `traffic` | `object` | Per-interface network traffic stats (interface name → counters) | + +--- + +### Sysctl (daemon-only) + +There is **no** `POST /api/network/sysctl/set` webui route. Setting a sysctl kernel parameter is a daemon-only endpoint, `POST /network/sysctl/set` (reached directly over the daemon socket, not via the WebUI). + +It sets the value via `sysctl -w` and verifies by reading it back. Only a fixed allowlist of nine keys is permitted: + +| `net.ipv4.ip_forward` | `net.ipv4.conf.all.forwarding` | `net.ipv4.conf.all.accept_redirects` | +| `net.ipv4.conf.default.accept_redirects` | `net.ipv4.conf.all.send_redirects` | `net.ipv4.conf.default.send_redirects` | +| `net.ipv4.conf.all.rp_filter` | `net.ipv4.icmp_echo_ignore_all` | `net.ipv4.tcp_syncookies` | **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) | +| `name` | `string` | Yes | Kernel parameter name (must be one of the nine allowed keys) | | `value` | `string` | Yes | Value to set | **Response (`data`):** @@ -2106,7 +2331,7 @@ Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it b | `name` | `string` | Parameter name | | `value` | `string` | Value set | -Returns HTTP `500` if the value cannot be verified after write. +Returns HTTP `400` if `name`/`value` is missing, `name` is malformed, or `name` is not in the allowlist. Returns HTTP `500` if the value cannot be verified after write. --- diff --git a/docs/architecture.md b/docs/architecture.md index 1ebe6b3..e45e5be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,10 +10,11 @@ The following describes the path a request takes from an external client to a ba 2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS). 3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate. 4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `config/nginx/config.json`. -5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive. -6. The backend service processes the request and returns an HTTP response. -7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response. -8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface. +5. If the domain has an `auth` block, nginx applies HTTP Basic authentication before proxying. Auth is resolved in the order domain → backend (the effective `auth` is the domain's own, or the referenced backend's if the domain has none), and per-path behavior follows the resolved auth config. Credentials are checked against the generated `data/nginx/.htpasswd` file; unauthenticated requests receive a 401 with a `WWW-Authenticate` challenge. Domains without an `auth` block skip this step entirely. +6. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via a `proxy_pass` directive (per-path upstreams resolved from the backend's `paths` config). +7. The backend service processes the request and returns an HTTP response. +8. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response. +9. nginx encrypts the response with TLS and sends it back to the client through the WAN interface. For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs. @@ -21,7 +22,7 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen 1. A client sends an HTTPS request to the management domain. 2. nginx terminates TLS and proxies the request to `127.0.0.1:9090` where the Flask WebUI is listening. No nginx-level authentication is applied. -3. Flask validates the JWT from the `Authorization: Bearer ` header, checks the token against the SQLite blacklist (`data/auth.db`), and verifies per-subsystem permissions before processing the request. Public endpoints (login, WebAuthn authenticate) are exempt from validation. +3. Flask validates the JWT from the `Authorization: Bearer ` header together with the `X-Session-Id` header (both are required; the session ID must match the token's `session_id` claim), checks the token against the SQLite blacklist (`data/auth.db`), and verifies per-subsystem permissions before processing the request. Public endpoints (login, token refresh, WebAuthn authenticate) are exempt from validation. 4. The Flask application communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations. 5. The daemon executes the privileged commands via the sudo whitelist and returns structured results. 6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection. @@ -33,28 +34,40 @@ Because Flask binds only to `127.0.0.1`, it is unreachable directly from any ext The following diagram summarizes how the Flask WebUI communicates with each managed subsystem: ``` -External Client ──→ nginx (SSL termination, NO auth) ──→ Flask WebUI (127.0.0.1:9090, JWT + permission check) +External Client ──→ nginx (SSL termination; auth_basic only on proxy domains with an `auth` block) ──→ Flask WebUI (127.0.0.1:9090, JWT + X-Session-Id + permission check) Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server) Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db) vacuum-walld ──→ daemon/handlers/auth.py ──→ lib/auth.py ──→ JWT operations vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables -vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload -vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq -vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider -vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 -vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload +vacuum-walld ──→ daemon/handlers/nginx.py ──→ render temps in /run/vacuum-wall ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload (SIGHUP) +vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ /run/vacuum-wall/dnsmasq.tmp ──→ sudo cp to /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq +vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ on issue/renew success: deploy hook (sudo nginx -t && sudo nginx -s reload) ──→ nginx +vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render per-class config (wg-) ──→ /run/vacuum-wall/.conf.tmp (0600) ──→ sudo cp to /etc/wireguard/.conf ──→ sudo wg-quick up +vacuum-walld ──→ daemon/handlers/network.py ──→ render 99-.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload + sudo networkctl reconfigure vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal +vacuum-walld ──→ daemon/handlers/status.py ──→ cross-subsystem apply-all (networkd→firewall→wireguard→dnsmasq→nginx) + cancel-all (revert to last applied) +vacuum-walld ──→ daemon/handlers/system.py ──→ pre-collected /proc metrics (state store) ``` +Notes on the diagram: + +- The ACME flow terminates at nginx: acme.sh stores certs on disk and the `deploy` step fires the deploy hook (`system/acme-deploy.sh`, installed to `$ACME_HOME/deploy/` by the install script) only after a successful issue or renewal; the hook runs `sudo nginx -t && sudo nginx -s reload`. A Python hook variant (`system/acme-deploy.py`) that instead calls the daemon's `POST /nginx/reload` endpoint exists in the tree but is not the one the install script installs. +- WireGuard multi-interface mode: the config defines `access_classes`; each class with peers gets its own interface `wg-`, rendered to `/etc/wireguard/wg-.conf`. Legacy single-interface mode renders `/etc/wireguard/wg0.conf`. + ### Two-User Model with Shared Group Vacuum Wall uses two distinct system users bridged by a shared group: -- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). +- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). Its systemd unit declares `RuntimeDirectory=vacuum-wall nginx` (pre-creates `/run/vacuum-wall` and `/run/nginx` before namespace setup) and `LogsDirectory=vacuum-wall` (`/var/log/vacuum-wall`; the management unit declares the same `LogsDirectory`). - **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`. -- **Shared group**: Both users share the WebUI user's primary group. The daemon socket is owned by `vacuum-walld:` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by the WebUI user with group-read+execute, giving the daemon read access to configs and shared files. +- **Shared group**: Both users share the WebUI user's primary group. The daemon socket is owned by `vacuum-walld:` with mode `0660`, allowing the web UI user to connect via group permission. In **production**, the project directory is owned by the **daemon user** with group read+write (`g+rwX`) and the setgid bit on all subdirectories, so the WebUI user can read configs and shared files via the shared group. In **`--dev` mode only**, the project directory stays owned by the repo owner (the WebUI user). -This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`. +This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. All *mutating* privileged operations live in `daemon/handlers/*.py`, but a few `lib/` code paths still execute sudo and are only ever called from within the daemon process: + +- `lib/common.get_interface_ip` — `sudo ip -o addr show ` (used by sync subscribers and handlers to backfill gateway addresses) +- `lib/system_import.import_firewall` — `sudo firewall-cmd --list-all-zones` (startup import only) +- `lib/nginx.test_config` — `sudo nginx -t` (imported live by `daemon/handlers/acme.py` for ACME pre-flight checks) +- Legacy sudo code in `lib/nginx.py` (install/reload helpers) and `lib/wireguard.py` (legacy apply/down paths) **Dev mode variant**: When `scripts/install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group. @@ -66,14 +79,14 @@ Vacuum Wall uses JWT-based authentication with access/refresh token rotation. To | Token | Lifetime | Storage | Purpose | |---|---|---|---| -| Access | 15 min | sessionStorage / memory | API auth, permission checks | +| Access | 5 min on fresh install (config-driven; code fallback 900 s) | sessionStorage / memory | API auth, permission checks | | Refresh | 7 days | sessionStorage | Token rotation, new access tokens | -JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`. +Every JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), and `type` (`"access"` or `"refresh"`). **Access tokens additionally carry `permissions` (per-subsystem permissions) and `session_id` (session binding — always present; a fresh ID is generated if the caller does not supply one). Refresh tokens carry only `session_id`, and only when a session was bound at login — they never carry `permissions`.** The Flask middleware relies on both: it reads `permissions` from the access token and cross-checks the `X-Session-Id` request header against the token's `session_id` claim. Each user has a unique signing secret stored in the `users.jwt_secret` database column (generated as a 32-byte base64url token via `secrets.token_urlsafe(32)`). This per-user secret model means tokens signed for one user cannot be validated as another user's tokens. Both Flask and daemon processes validate tokens by extracting `sub` from the unverified payload, looking up the user's secret, and verifying the signature with that secret. Expired and blacklisted tokens are rejected against the SQLite `token_blacklist` table (via `data/auth.db`). -Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. The blacklist is cleaned of expired entries on every refresh operation. +Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. Blacklist cleanup is **probabilistic, not per-refresh**: `blacklist_token()` purges expired entries with a 2% chance on each call, and the daemon's poll loop additionally runs `blacklist_expired()` at most every 60 seconds (coordinated across all poll loops via a shared lock). ## Permission Model @@ -86,7 +99,7 @@ Flask `before_request` middleware enforces permissions by extracting the subsyst The `auth` subsystem controls user management. User CRUD endpoints (`/api/auth/users/*`) require `auth: "rw"` ("admin required"). -Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`. +Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`. (The SPA root `GET /` is also exempt.) Note that for all *other* API routes the middleware requires **both** the `Authorization: Bearer ` and `X-Session-Id` headers — a request with only one of the two is rejected with 401. ## Database Layer @@ -113,7 +126,7 @@ Environment variables (not config files) control database access: | `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection | | `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path | -Both Flask (`webui/server.py`) and daemon (`daemon/server.py`) call `get_db()` at startup. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite. +Flask (`webui/server.py`) calls `get_db()` once at startup to open (and initialize) the database. The daemon does **not** call `get_db()` at startup — it reaches the database lazily through `lib.auth` / `lib.auth_users` the first time an auth operation actually runs. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite. ## Install-Time Templating @@ -134,8 +147,8 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi | firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` is a pre-apply recovery snapshot (`{timestamp, default_zone, zones, config}`) written before every apply; `zones` is the permanent firewalld zone view. | | dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. | | nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. | -| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. | -| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. | +| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/.conf` — per-class `wg-.conf` in multi-interface mode; legacy single-interface `wg0.conf` | The JSON file defines the interface, `access_classes`, and all peers. In multi-interface mode each class with assigned peers renders to its own `/etc/wireguard/wg-.conf` (class interface `wg-`) and is brought up independently; apply temps live in `/run/vacuum-wall/`. Rendered configs are overwritten on each apply. | +| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/99-.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `99-.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. | | ACME | `config/acme/config.json` | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. Account registration (email, CA provider) is stored in the declarative config. | #### Background Polling @@ -149,49 +162,90 @@ The daemon runs background polling tasks for subsystems with external runtime st | 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 | 60s | Drift re-collection — the collector is a **pure re-read** of the JSON config and rendered artifacts on disk, so polling re-collects manual edits and out-of-band applies. The one-shot nginx legacy-format migration itself is *not* part of the read path — it runs once at startup via `lib/bootstrap.py` (`nginx.migrate_config_file()`). | +| acme | 300s | Drift re-collection — the collector is a **pure re-read** of acme.sh state. The only self-heal in the ACME path is `normalize_acme_home()` (reopens group access on the acme.sh tree, which acme.sh hardens to owner-only on every run). | -Only `auth` is not polled — it has no external runtime state. +All 7 state subsystems are polled. `auth` is **not** a state subsystem at all — auth data lives in the SQLite DB and is fetched on demand by Flask and the daemon, so it has no poll loop and no WS stream. **Two-layer diff:** Each poll cycle classifies changes as: - **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: `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()`. +Volatile fields per subsystem, defined per collector via `register_volatile()`: `system` (load/memory/swap/traffic), `firewall` (`interfaces[].ips` / `interfaces[].ipv6`), `networkd` (`interfaces[].addresses` only), `wireguard` (peer transfer/handshake stats — both the combined `status.peers[].*` and the per-class `status.classes[].peers[].*`). 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`, 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). +On collector failure **during a poll**, no broadcast is sent (avoids noisy ticks) and the existing state is **kept** — `poll()` returns "no change" and does not clear the stored data (only `populate()` clears a subsystem's state to `None` when its collection fails). `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). + +## Apply Bookkeeping and Pending Changes + +Every config-backed subsystem records its last-applied state in two keys inside +its declarative JSON: `_last_applied_hash` (SHA-256 of the meta-stripped config) +and `_last_applied_config` (a snapshot of the config at apply time). The helpers +live in `lib/common.py`: + +- **`stamp_applied(cfg)`** — writes both keys. Called by each subsystem's apply + handler after a successful apply. +- **`compute_pending(cfg)`** — returns `(pending, diff)`. Pending when the hash + is missing or stale; `diff` is a field-level `deep_diff()` between the + recorded snapshot and the current (meta-stripped) config. +- **`strip_apply_meta(cfg)` / `config_hash(cfg)`** — ignore the bookkeeping keys + when hashing or comparing configs. +- **`revert_to_applied(path)`** — rewrites a config file from its + `_last_applied_config` snapshot (re-stamped so the pending check reports it as + up to date); returns a reason instead when no baseline is recorded (never + applied). + +The `status` handler exposes this cross-subsystem: + +- **`GET /status/pending`** — aggregates pending changes per subsystem (the + firewall section additionally carries the advisory `uncovered_interfaces` + list). +- **`POST /status/apply-all`** — applies pending subsystems in dependency order + — **networkd → firewall → wireguard → dnsmasq → nginx** — calling each + subsystem's apply handler. `{"force": true}` is forwarded only to the + firewall apply, where it overrides the management-lockout and + interface-coverage guards. +- **`POST /status/cancel-all`** — reverts every pending subsystem's config file + to its last-applied snapshot (subsystems with no recorded baseline are + skipped with a reason). Cancel touches only the declarative config files — + it never runs live-system commands. ## System Config Import -On daemon startup, `lib/system_import.py` reconciles live system configurations -with the declarative JSON configs. This ensures that configurations created -by `scripts/install.sh` or edited manually in system files are imported into -the JSON source of truth, preventing drift. +On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py` +to reconcile live system configuration with the declarative JSON configs. This +ensures that configurations created by `scripts/install.sh` or edited manually +in system files are imported into the JSON source of truth, preventing drift. -When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem -import function: +Each subsystem import function parses the corresponding live system config and +updates the JSON config when they differ: - **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed block between comment markers) → `config/dnsmasq/config.json`. Only writes if config doesn't exist or differs. - **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf` → `config/wireguard/config.json`. Skips if configs match. -- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files - (install-time files) → `config/network/config.json`. Only adds/updates - interfaces; doesn't remove interfaces without a file (they may be pending apply). -- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf` → - `config/nginx/config.json`. Only touches vacuum-wall-managed files - (identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`. +- **`import_networkd`**: Globs **all** `/etc/systemd/network/*.network` files → + `config/network/config.json`, stripping any numeric priority prefix from the + filename (`99-eth0.network` → `eth0`; `eth0.network` → `eth0`). Only + adds/updates interfaces; doesn't remove interfaces without a file (they may + be pending apply). +- **`import_nginx`**: **Skips entirely if `config/nginx/config.json` already + exists** — it only bootstraps the declarative config from rendered + `data/nginx/sites-enabled/*.conf` (vacuum-wall-managed files identified by + the `# Auto-generated by Vacuum Wall` header; `_acme-challenge.conf` is + skipped) on hosts where the JSON config is absent. When the config exists it + wins: re-parsing generated server blocks is lossy (backend references get + flattened to inline paths). - **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones` → `config/firewall/config.json`. Only writes if no config file exists (firewalld state always takes precedence). -Import failures are silently logged as warnings — they never abort daemon startup. -The returned list of updated subsystems is logged for debugging. +All imports are **idempotent** and **non-destructive**: they only write when +configs differ, skip on failure (logged as warnings), and never abort daemon +startup. The returned list of updated subsystems is logged for debugging. ## Cross-Subsystem Sync Event Bus @@ -207,16 +261,27 @@ subsystems — no handler calls into another handler's logic directly. - **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone for each interface serving a DHCP range. Back-propagates gateway (interface IP) into DHCP ranges so clients receive their default route. - - **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with - WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone - accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created - entries when no active peers exist. - - **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer - in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the - gateway (interface IP). Logs warnings for zones with dhcp service but no range. - - **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without - ranges. Syncs firewall zone interface assignments — adding new interfaces - and removing stale ones no longer in network config. + - **WgToFirewallSync**: Manages **per-access-class** firewall zones: for each + access class with peers, ensures a `vpn-` zone exists with the class's + WireGuard interface (`wg-`), masquerade enabled, and a UDP accept + rich rule on the class's `listen_port` (default 51820). Classes with + `lan_access: true` additionally get inter-zone accept rules for internal + subnets (derived from zones without masquerade); `lan_access: false` + (internet-only) classes get no internal rules. Stale class zones + (`vpn-` whose class no longer has peers) have their WireGuard-created + entries cleaned up. The single `vpn`/51820 zone is managed **only as a + legacy fallback** when peers exist without an `access_class`; when + WireGuard is fully inactive all WireGuard-created entries (interface, + masquerade, `_source: wg` rules) are removed from the legacy zone. + - **FirewallToDhcpSync**: **Never deletes** DHCP ranges. Ranges whose + interface no longer belongs to any zone are kept in config and flagged + inactive (advisory warning). The only mutation is backfilling the + `gateway` (interface IP) on ranges for zones with masquerade enabled. + Zones with the `dhcp` service but no range are logged. + - **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without + ranges (advisory only). New network interfaces are logged/flagged but + **never added** to zones; the only mutation is removing interfaces no + longer present in the network config from the zones that still list them. 4. The handler refreshes state for the originating subsystem plus all transitively affected subsystems. @@ -238,27 +303,29 @@ Minimal. The sync happens transparently in the backend. The "pending changes" indicator on the firewall page will show pending when DHCP or WireGuard saves (since sync writes JSON but does not call firewall-cmd). -## System Config Import +## Daemon Startup Order -On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py` -to reconcile any drift between system configuration files and the declarative -JSON configs. This is invoked from `daemon/server.py` during initialization. +The daemon's `main()` (`daemon/server.py`) runs a fixed startup sequence after +the aiohttp app is listening on the Unix socket and WebSocket port: -Each subsystem import function parses the corresponding live system config and -updates the JSON config if they differ: - -| Subsystem | Source | Condition | -|---|---|---| -| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers | -| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet | -| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format | -| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files | -| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks | - -All imports are **idempotent** and **non-destructive**: they only write when -configs differ, skip on failure (logged as warnings), and never abort daemon -startup. This ensures that manual edits to system files (e.g., during install -or troubleshooting) are reconciled into the declarative JSON source of truth. +1. **`system_import.import_all()`** — reconciles live system configs into the + declarative JSON (see System Config Import above). Runs **first** because it + must see absent config files in order to adopt live system state on first + start. +2. **`bootstrap()`** (`lib/bootstrap.py`) — creates the runtime `config/` + + `data/` directories for all subsystems and persists the one-shot nginx + legacy-format migration (`nginx.migrate_config_file()`). Idempotent. It + deliberately never creates config *files*: `get_config` reads are pure + (missing file → in-memory defaults), so files are materialized on the first + `save_config` (or by the import itself). +3. **`normalize_acme_home()`** — reopens group access on the acme.sh tree + (acme.sh hardens it to owner-only on every run); a failure here is logged, + never fatal. +4. **First `state_store.populate()`** — collects all subsystem state; the + version counter of every successfully populated subsystem is bumped so the + first WS snapshot is followed by a `versions` broadcast. +5. **`start_polling(loop)`** — spawns one background poll task per subsystem + (intervals per Background Polling above). ## Directory Structure @@ -288,7 +355,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact ``` data/ -├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds +├── auth.db # SQLite database: users, permissions, token_blacklist, refresh_tokens, webauthn_creds, init_sequence ├── nginx/ │ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only) │ └── sites-enabled/ # Generated nginx server block .conf files (one per domain) @@ -300,14 +367,14 @@ data/ ├── logs/ │ └── vacuum-wall.log # Application log file └── wireguard/ # WireGuard runtime artifacts -├── networkd/ # Generated 50-.network files +├── networkd/ # Generated 99-.network files ``` Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time. The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop. -`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends). +`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` and `/run/nginx.pid` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon; nginx rewrites the pid file on start). `/run/nginx.pid` is additionally listed in the unit's `ReadWritePaths=`: the daemon's `nginx -t` opens the pid file for *writing*, so a read-only mount would fail every daemon-side `nginx -t` (and therefore `/nginx/apply`) with EROFS — the tmpfiles entry guarantees the file exists at spawn. `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends). ## File System Layout @@ -318,14 +385,15 @@ The following file system locations are used for integration with system service | `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) | | `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) | | `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) | -| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) | -| `/etc/systemd/network/50-.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) | +| `/etc/wireguard/.conf` | Generated WireGuard interface configuration, written from `config/wireguard/config.json`. Per-class `wg-.conf` in multi-interface mode; legacy single interface `wg0.conf`. | Vacuum Wall (lib/wireguard.py) | +| `/etc/systemd/network/99-.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) | | `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | -| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) | +| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq, wireguard, networkd). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) | | `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) | +| `/run/nginx.pid` | nginx pid file. Must exist at spawn **and** be writable by the daemon: its `nginx -t` opens the file for writing, so it needs both a boot-time creator (`system/tmpfiles.d/vacuum-wall.conf`) and a `ReadWritePaths=` entry (nginx rewrites it on start). | nginx / systemd-tmpfiles (early boot) | | `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) | | `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) | -| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) | +| `data/auth.db` | SQLite database: users, permissions, token_blacklist, refresh_tokens, webauthn_creds, init_sequence. Created on first access via `get_db()`. | Auth layer (lib/db.py) | The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. @@ -337,15 +405,16 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh ``` Client requests / ──→ nginx ──→ Flask (serves index.html) -Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login +Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, auth model 'check' fetch action (GET /api/auth/session; not-ok with a stored refresh token → exactly one refresh) → if no valid session, render #login Authenticated ──→ mounts #sidebar and #main render roots -apiFetch() ──→ injects Authorization: Bearer header ──→ Flask REST API -Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions +apiFetch() ──→ injects BOTH Authorization: Bearer and X-Session-Id headers ──→ Flask REST API +Flask before_request ──→ validates JWT + session ID from headers, checks blacklist, verifies permissions Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer ` subprotocol + X-Auth-Token header fallbacks accepted) Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld -Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens +Token expiry ──→ TTL-driven scheduleRefresh() (timer at access-token TTL − 60s, min 30s) ──→ POST /api/auth/refresh ──→ new tokens 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 +WS close ×3 ──→ refreshAuth() + reconnect; 2 consecutive failed refresh+reconnect episodes ──→ give up: no more reconnect attempts until the page is reloaded (REST API keeps working) ``` The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/` route serves vendored JS libraries. On the management domain, nginx serves `/static/` directly from `webui/static/` via a `location /static/` alias in the generated server block, so asset requests never reach Flask in production; Flask's static route remains as the dev-mode fallback. @@ -358,10 +427,22 @@ 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: the management domain's `/static/` assets carry `Cache-Control: no-cache` (browsers revalidate every load; unchanged files return 304 via nginx's built-in ETag), so updates are picked up on the next page load. Dev mode (`VACUUM_WALL_DEV`) uses short TTLs instead. +Because there is no build step, backend changes can be hot-reloaded too: the `vacuum-wall` systemd unit defines `ExecReload=` which sends SIGHUP to the Flask process — Flask auto-reloads its `webui.*` and `lib.*` modules and then restarts itself, so `systemctl reload vacuum-wall` picks up code changes without a full stop/start. + ### WebSocket Data Streaming 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. +## Firewall Interface-Coverage Invariant + +A core apply-path guarantee: every network-managed interface (`lo` and `wg*` excluded) must be covered by a zone in `config/firewall/config.json` **or** listed under the top-level `unmanaged` key. The config is the source of truth for zone interfaces — an omitted `interfaces` key counts as an empty list, so there are no hands-off zones. + +The check is the pure `lib.firewall.validate_coverage()`, enforced at: + +- **Save time** — `POST`/`PATCH /firewall/config` returns 400 when the proposed config would leave an interface uncovered. +- **Apply time** — `POST /firewall/config/apply` returns 409 on coverage failure; `{"force": true}` (e.g. from the status apply-all endpoint) overrides the guard. +- **Live drift is advisory only** — the firewall state carries an `uncovered_interfaces` field and the status pending summary surfaces it, but live coverage never blocks a save or apply on its own. + ## Zone Model The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level: diff --git a/docs/config.md b/docs/config.md index 9645f1c..87aaab7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -48,7 +48,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d | Field | Type | Required | Description | |---|---|---|---| | `ranges` | array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: `[]`. | -| `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). | +| `ranges[].interface` | string | No | Network interface on which to serve this DHCP range (e.g., `eth1`). Omit for a global range served on all interfaces (renders an untagged `dhcp-range`). | | `ranges[].start` | string | Yes | First IP address in the pool. | | `ranges[].end` | string | Yes | Last IP address in the pool. | | `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `12h`. | @@ -56,7 +56,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d | `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. | | `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: `[]`. | | `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). | -| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. | +| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Vacuum Wall does not validate that this is outside the dynamic pool ranges — keep it outside the pool to avoid address conflicts. | | `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. | ### DNS Fields @@ -76,42 +76,15 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil **File**: `config/nginx/config.json` -This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`. +This file defines named backends (path-based routing definitions), reverse proxy domains that reference those backends, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/`, the include file `/etc/nginx/conf.d/vacuum-wall.conf` (which also defines the `$connection_upgrade` map used for WebSocket pass-through), the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`, and a catch-all ACME challenge site at `data/nginx/sites-enabled/_acme-challenge.conf` (a port-80 `default_server` serving `/.well-known/acme-challenge/` from the `data/acme/www` webroot for domains without a dedicated server block yet). ```json { - "domains": { - "app.example.com": { - "force_ssl": true, - "cert": "acme", - "auth": { - "user": "admin", - "htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" - }, - "paths": { - "/": { - "backend": { - "host": "192.168.2.50", - "port": 8080, - "proto": "http" - }, - "headers": { - "X-Forwarded-Proto": "https" - } - }, - "/api": { - "backend": { - "host": "192.168.2.51", - "port": 3000, - "proto": "http" - }, - "auth": null - } - } - }, - "mgmt.example.com": { - "force_ssl": true, - "cert": "acme", + "backends": { + "webui": { + "label": "Vacuum Wall WebUI", + "builtin": true, + "_migrated": true, "paths": { "/": { "backend": { @@ -120,10 +93,7 @@ This file defines reverse proxy domains with path-based routing, and global SSL "proto": "http" }, "is_management": true, - "auth": { - "user": "admin", - "htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" - } + "auth": null }, "/ws": { "backend": { @@ -134,6 +104,37 @@ This file defines reverse proxy domains with path-based routing, and global SSL "is_websocket": true } } + }, + "nas": { + "label": "NAS", + "paths": { + "/": { + "backend": { + "host": "192.168.2.50", + "port": 8080, + "proto": "http" + }, + "headers": { + "X-Forwarded-Proto": "https" + } + } + }, + "auth": { + "user": "admin", + "htpasswd": "data/nginx/.htpasswd" + } + } + }, + "domains": { + "app.example.com": { + "force_ssl": true, + "cert": "acme", + "backend": "nas" + }, + "mgmt.example.com": { + "force_ssl": true, + "cert": "acme", + "backend": "webui" } }, "ssl": { @@ -144,38 +145,58 @@ This file defines reverse proxy domains with path-based routing, and global SSL } ``` -### Domain Entries +### Backends -The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends. +The `backends` object maps backend names (keys) to shared routing definitions. Each backend carries the path map and an optional auth block; domains reference a backend by name and serve all of the backend's paths. Paths live on the backend — a domain entry never carries inline `paths`. | Field | Type | Required | Description | |---|---|---|---| -| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. | -| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. | -| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. | -| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path level. | +| `label` | string | Yes (on create) | Human-readable display name for the backend. Required when adding via `POST /nginx/backends/add`. | +| `paths` | object | Yes | Path-to-config map (schema in [Path Entries](#path-entries) below). | +| `auth` | object | No | Backend-level HTTP basic auth (`{ user, htpasswd }`). Used by any domain referencing this backend unless overridden at the domain level. | +| `builtin` | boolean | No (read-only) | Read-only flag set on the built-in `webui` backend. Builtin backends cannot be modified or removed. | +| `_migrated` | boolean | No (internal) | Internal marker set by the legacy-format migration. Not user-settable; stripped from API responses. | + +Backends are managed through the daemon endpoints `GET /nginx/backends` (secrets stripped; each entry reports a `has_auth` boolean instead of the auth object), `PATCH /nginx/backends` (deep-merge partial update; `auth: null` or `auth: false` removes auth), `POST /nginx/backends/add` (creates a new backend; `400` if the name already exists), and `DELETE /nginx/backends/remove` (`400` for builtin backends, `409` when a domain still references the backend). ### Path Entries -Each entry under `paths` defines a location block and its proxy backend. +Each entry in a backend's `paths` map defines an nginx `location` block and its proxy target. | Field | Type | Required | Description | |---|---|---|---| | `backend` | object | Yes | The upstream service for this path. | | `backend.host` | string | Yes | IP address or hostname of the backend service. | | `backend.port` | integer | Yes | Port the backend service is listening on. | -| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. | -| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. | -| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain-level auth. `null` disables auth for this path. | -| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. | +| `backend.proto` | string | Yes | Protocol: `http` or `https`. Required — no default; absence is a validation error when adding or updating a backend. | +| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. Not rendered on `is_management` paths. | +| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain/backend-level auth. `null` renders `auth_basic off` for this path. | +| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. The server block gets a `/static/` alias block serving `webui/static/` from disk (with `no-cache` revalidation), uses the dedicated `wall_mgmt_access.log` / `wall_mgmt_error.log` log files, and suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. | | `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. | +### Domain Entries + +The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block and references a shared backend by name — all of that backend's paths are served under the domain. + +| Field | Type | Required | Description | +|---|---|---|---| +| `backend` | string | Yes | Name of the backend (in `backends`) to proxy through (e.g., `"webui"`). Must reference an existing backend. | +| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. | +| `cert` | string \| object | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. | +| `cert_path` | string | No | For `cert: "file"`: path to the certificate file. (Also settable as `cert: { "cert_path": ..., "cert_key_path": ... }` in dict form.) | +| `cert_key_path` | string | No | For `cert: "file"`: path to the private key file. | +| `auth` | object \| null | No | Domain-level HTTP basic auth override (`{ user, htpasswd }`). Takes precedence over the referenced backend's `auth`; see [Auth Inheritance Rules](#auth-inheritance-rules). | + ### Auth Inheritance Rules -- Domain-level `auth` applies to all paths unless overridden. +Effective auth for a domain is resolved in order: **domain `auth` → referenced backend `auth` → `None`**. + +- A domain `auth: { ... }` overrides the referenced backend's auth for that domain; a domain without an `auth` key falls back to the backend's. - Path-level `auth: null` means "no auth" for that path. -- Path-level `auth: { ... }` overrides domain-level for that path. -- No other domain-level settings inherit — `headers` is path-only. +- Path-level `auth: { ... }` overrides for that path. +- No other settings inherit between backends and domains — `headers` is path-only. + +**API auth form.** When adding or updating a domain through the API, `auth` may be given as `{ user, pass }`. The daemon writes the password into the `.htpasswd` file (SHA-256 crypt, default `data/nginx/.htpasswd`, or the `htpasswd` path supplied in the auth object) and persists only `{ user, htpasswd }` — the raw password is never stored in the config. ### Path ordering @@ -188,12 +209,14 @@ The `cert` field is a string that selects the provisioning method: | Value | Description | |---|---| | `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at `/.well-known/acme-challenge/`. | -| `file` | Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. | +| `file` | Use a pre-existing certificate and private key from the local file system, via the domain's `cert_path` / `cert_key_path` fields (or the dict form `cert: { "cert_path": ..., "cert_key_path": ... }`). Vacuum Wall will not attempt to renew these certificates. | | `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. | ### Management Domain -The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key. +The Vacuum Wall admin interface is configured as a regular domain entry under `domains` that references the built-in `webui` backend (`"backend": "webui"`). That backend carries `is_management: true` on the root path (Flask app) and a `/ws` path with `is_websocket: true` for WebSocket pass-through. Because the built-in `webui` backend's root path has `auth: null`, the management path never gets nginx basic auth — management authentication is the Flask-layer JWT (bearer tokens); nginx `auth_basic` would suppress the SPA's Bearer requests. This replaces the legacy inline-`paths` form in which the management domain carried its own root and `/ws` paths (see [Backward Compatibility](#backward-compatibility)). + +For a management domain without an explicit `cert` (or with `cert: "selfsigned"`), the apply step auto-generates a self-signed certificate at `data/certs/.crt` / `data/certs/.key` (RSA-2048, 365 days) if one is not already present. The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible: @@ -203,9 +226,11 @@ htpasswd -bc data/nginx/.htpasswd admin yourpassword ### Backward Compatibility -Config files using the legacy format are auto-migrated on first load: -- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`. -- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path. +Config files using the legacy format are auto-migrated. The migration runs in-memory on every config read and is persisted to disk one-shot at daemon startup. It performs three steps: + +1. Materializes the builtin `webui` backend (marked `_migrated: true`). The daemon handler's migration pass additionally harvests the legacy management domain's root-path auth into `backends.webui.auth`. +2. Rewrites legacy management domains (a root path pointing at `127.0.0.1:9090` with `is_management` and a `/ws` path pointing at `127.0.0.1:9091` with `is_websocket`) to `"backend": "webui"`, deleting their inline `paths` and `auth`. +3. Strips the legacy `application: "webui"` key. ### Global SSL Settings @@ -235,16 +260,16 @@ This file stores the ACME account settings used by acme.sh for certificate provi | Field | Type | Required | Description | |---|---|---|---| | `email` | string | No | Contact email for the ACME account. Used for certificate expiry notifications and recovery. Populated automatically when an account is registered via the WebUI. Default: `""`. | -| `ca` | string | No | ACME CA provider. One of: `"letsencrypt"` (Let's Encrypt), `"zerossl"` (ZeroSSL). Populated automatically when an account is registered. Default: `""`. | +| `ca` | string | No | ACME CA server. Any `server` string — passed through verbatim to `acme.sh --server` (e.g., `letsencrypt`, `zerossl`, or a private/staging CA). Not a closed enum. Populated automatically when an account is registered (the WebUI defaults to `letsencrypt` when no server is given). Default: `""`. | ### Account Registration ACME account registration is handled entirely through the WebUI. When the user registers an account: 1. The user navigates to the Certificates page and clicks "Register Account". -2. Provides an email address and selects a CA provider (Let's Encrypt or ZeroSSL). -3. The backend calls `acme.sh --register-account` with the provided parameters. -4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its `.account.conf` file under `data/acme/`. +2. Provides an email address and a CA server (defaults to `letsencrypt`). +3. The backend calls `acme.sh --register-account -m --server `. +4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its account state under `data/acme/` (modern acme.sh v3.x writes `account.conf`, without a leading dot). Before any certificate can be issued, an ACME account must be registered. The certificate validation flow includes a blocking check (`account_registered`) that prevents issuance if no account exists. @@ -252,17 +277,21 @@ Before any certificate can be issued, an ACME account must be registered. The ce After registration, the account can be managed from the WebUI: -- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -u`. +- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -m ` (there is no `-u` flag; re-running account registration with the new email updates the account). - **Deactivate account**: The Settings modal includes a button to deactivate the account via `acme.sh --deactivate-account`, which clears the `email` and `ca` fields and removes the ACME account. ### ACME Home Directory acme.sh stores its state under `data/acme/` (the ACME home directory). Key files: -- `.account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`). -- `/` — Per-domain certificate and key files issued by acme.sh. +- `account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`). Modern acme.sh (v3.x) writes `account.conf` (no leading dot); older v2.x wrote `.account.conf`, and both names are still recognized. +- `ca//` — Per-CA account files, keyed by the ACME server name (e.g., `ca/letsencrypt/`). +- `/` — Per-domain certificate and key files issued by acme.sh. For ECC certificates the directory is `_ecc/`; `find_cert_dir()` checks the `_ecc` directory first, then the plain `/` directory. +- `www/` — ACME HTTP-01 webroot. Challenge files are served from here by nginx. -The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered. +The application determines registration status in this order: `account.conf` → `.account.conf` → the declarative `config/acme/config.json` (kept in sync by the register/email handlers). If no source yields both an email and a CA, the account is considered unregistered. + +In addition to ACME-issued certificates, `POST /acme/self-signed` (daemon endpoint) generates a self-signed certificate for a domain under `data/certs/` (takes a `days` parameter, default `365`; idempotent — skips generation when the cert and key already exist). ## Auth Configuration @@ -278,9 +307,8 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the "algorithm": "HS256" }, "webauthn": { - "rp_name": "Vacuum Wall", - "rp_id": "", - "origin": "https://" + "enabled": true, + "rp_name": "Vacuum Wall" } } ``` @@ -289,7 +317,7 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the | Field | Type | Required | Description | |---|---|---|---| -| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). | +| `access_token_ttl` | integer | No | Access token lifetime in seconds. Code fallback default: `900` s; the fresh-install bootstrap writes `300` s (5 min). | | `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). | | `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. | @@ -297,15 +325,18 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the ### WebAuthn Fields +The WebAuthn config block holds only two fields: + | Field | Type | Required | Description | |---|---|---|---| -| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. | -| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. | -| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://`. | +| `enabled` | boolean | No | Whether WebAuthn is enabled. Default: `true`. | +| `rp_name` | string | No | Display name for the WebAuthn Relying Party. Shown during credential registration. Default: `"Vacuum Wall"`. | + +`rp_id` and `origin` are **not** config fields. They are derived per-request from the management domain the request arrives on and validated against the live management domains (the WebAuthn endpoints refuse domains that do not serve the management UI). ## Database Schema -The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`. +The SQLite database at `data/auth.db` stores authentication data across six tables. Created automatically on first access via `get_db()`. ### users @@ -336,7 +367,17 @@ UNIQUE constraint on `(username, subsystem)`. | `token_type` | TEXT | `"access"` or `"refresh"` | | `expires` | INTEGER | Unix timestamp of token expiry | -Used to invalidate tokens on logout and password change. Expired entries are cleaned on every refresh operation. +Used to invalidate tokens on logout and password change. Expired entries are cleaned up by the daemon's periodic poll loop (at most every 60 seconds) and probabilistically (roughly 2% of the time) inside `blacklist_token()` — not on every refresh. + +### refresh_tokens + +| Column | Type | Description | +|---|---|---| +| `username` | TEXT | Primary key (unique) — the owning user | +| `jti` | TEXT | JWT unique identifier of the current refresh token | +| `issued_at` | INTEGER | Unix timestamp when the refresh token was issued | + +At most one active refresh session per user: the `username` column is unique, so issuing a new refresh token replaces the stored entry for that user. The active refresh token is blacklisted and removed on logout and password change. ### webauthn_creds @@ -352,6 +393,12 @@ Used to invalidate tokens on logout and password change. Expired entries are cle UNIQUE constraint on `(username, credential_id)`. +### init_sequence + +| Column | Type | Description | +|---|---|---| +| `seq` | INTEGER | Primary key — bookkeeping sequence marker | + ## WireGuard Configuration **File**: `config/wireguard/config.json` @@ -422,11 +469,13 @@ This file defines the WireGuard server interface, access classes, and all connec ### Access Classes -Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-``), firewall zone (``vpn-``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes//up`, `POST /api/wireguard/classes//down`. +Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-``), firewall zone (``vpn-``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes//up`, `POST /api/wireguard/classes//down` (the down route forwards to the daemon's `DELETE /wireguard/classes//down`). + +**Class key validation.** The class `key` (object key) must be lowercase alphanumeric — anything else is rejected (`400`). `name` defaults to the key when omitted. Creating a class whose key already exists raises `409 Conflict`. Deleting a class is refused with `409 Conflict` while any peer still references it (the response lists the offending peers). | Field | Type | Required | Description | |---|---|---|---| -| `name` | string | Yes | Human-readable display name for the class. | +| `name` | string | No | Human-readable display name for the class. Defaults to the class key when omitted. | | `description` | string | No | Optional description of what access level this class provides. Default: `""`. | | `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``.1/``. | | `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. | @@ -460,12 +509,14 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice | `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. | | `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. | | `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. | -| `description` | string | No | Optional description for the peer. Default: `""`. | +| `description` | string | No | Optional description for the peer. The API defaults it to `""` when a peer is added via the endpoint; the lib-level `add_peer()` stores `null` when the field is omitted. | | `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. | ### Client Configuration Generation -When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. `generate_client_conf()` computes the client IP address from the server's subnet and the peer's sorted index position. +When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. + +`generate_client_conf()` derives the client IP address and the `Endpoint` port from the peer's **access class** when the peer is class-assigned — the class's `subnet` and `listen_port` are used, not the server interface's. For unassigned peers it falls back to the server interface's `addresses[0]` and `listen_port`. The client's host index is the peer's position in the sorted list of **all** peer keys (across every class) plus 2 (index 1 is reserved for the server). ### Applying Configuration @@ -536,11 +587,12 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr | `forward_ports[].toaddr` | string | No | Internal IP address to forward to. Omit for broadcast forwarding. | | `forward_ports[].toport` | integer | No | Internal port to forward to. Omit to keep the same port. | | `rich_rules` | array | No | Rich rule entries for advanced firewall policies. Default: `[]`. | +| `rich_rules[].id` | string | No | Auto-generated unique identifier (8-hex UUID) for the rich rule. Not user-settable; assigned when the rule is added via the API. The `DELETE /firewall/rich-rules/remove` endpoint addresses rules by this `id`. | | `rich_rules[].rule` | string | Yes | The full firewalld rich rule string, e.g., `rule family="ipv4" source address="10.0.0.0/8" reject`. | ### Applying Firewall Configuration -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`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly. +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. Masquerade is **skipped for the `public` zone** in both the pending diff and the apply step — the public zone's masquerade is driven by the nftables propagation step described below, so diffing it would advertise a change that never happens. Zones that exist live but not in config are reported as `unmanaged_zones`, excluding the zones firewalld ships by default (`block`, `dmz`, `drop`, `external`, `home`, `host`, `internal`, `public`, `trusted`), which are always present live and never meaningful to flag. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly. Both `/api/firewall/zones//services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift. @@ -555,11 +607,13 @@ Send `"force": true` in the request body to override the apply-time check (the U **Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel. +**Public-zone masquerade propagation.** With firewalld's nftables backend, traffic leaving through the public zone hits the public zone's POSTROUTING chain, so NAT only works if the public zone itself has masquerade enabled. During apply, if any non-public zone has masquerade enabled but the public zone does not, apply propagates masquerade to the public zone (and writes it back into the config); conversely, when no non-public zone needs masquerade, apply removes it from the public zone. Consistently, the `POST /firewall/masquerade` endpoint **refuses** to enable masquerade on the `public` zone directly (enable it on `internal` or a `vpn` zone instead — the API returns an error directing you there). + ## Networkd (IP Configuration) **File**: `config/network/config.json` -This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`. +This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `99-.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`. ```json { @@ -609,7 +663,7 @@ Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`, | `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. | | `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. | | `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. | -| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. | +| `dhcp_client` | `object` | DHCP client settings. `[DHCPv4]` and `[DHCPv6]` have **different** key sets (which sections render is controlled by `dhcp`). Shared by both: `hostname`, `duid`, `duid_type`, `duid_raw_data`, `iaid`, `anonymize`, `rapid_commit`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_hostname`, `use_domains`, `net_label`, `nft_set`, `send_option`, `send_vendor_option`, `user_class`. IPv4-only (`[DHCPv4]`): `client_identifier`, `use_mtu`, `use_routes`, `route_metric`, `send_decline`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `vendor_class_identifier`, `request_options`. IPv6-only (`[DHCPv6]`): `send_hostname`, `prefix_delegation_hint`, `unassigned_subnet_policy`, `use_address`, `use_delegated_prefix`, `use_dnr`, `send_release`, `without_ra`, `vendor_class` (a list; each entry renders a `VendorClass=` line). | | `bind_carrier` | `array` | Carrier interfaces to bind to. | | `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. | | `keep_configuration` | `boolean` | Keep configuration on stop. | @@ -643,7 +697,7 @@ When `POST /api/network/apply` is called, the handler automatically collects pub ### Generated Files -Each interface config entry produces a `50-.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`). +Each interface config entry produces a `99-.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`). ## Cross-Subsystem Dependencies @@ -654,7 +708,7 @@ are updated automatically through the event bus. |---|---|---| | dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. | | wireguard (peer add/remove) | firewall | Per-class `vpn-` zones are created with `wg-` interface, masquerade, UDP port rule, and inter-zone accept rules (only when ``lan_access=true``). Falls back to single `vpn` zone in legacy mode. Cleanup removes stale rules when classes have no peers. | -| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. | +| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are **kept in the config and flagged inactive — never removed**. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. | | network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. | | network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. | diff --git a/docs/deployment.md b/docs/deployment.md index 6ce97cb..9b0cdc1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -40,7 +40,7 @@ All settings that can be passed as an environment variable also have a CLI flag | Flag | Env Var | Required | Description | |---|---|---|---| -| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** | +| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Auto-detected from the system hostname; defaults to `$(hostname -f \|\| hostname).local` (FQDN first, falling back to the short hostname; mDNS-served on the LAN). **Errors if the hostname is undetectable and this is not set.** | | `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) | | `--mgmt-pass` | `MGMT_PASS` | Yes | Password for the initial admin user (default: `admin`). Creates the admin user in the SQLite database with full `rw` permissions on all subsystems. | | `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. | @@ -64,9 +64,9 @@ The `--dev` flag is designed for developers working in a git clone. It auto-dete In dev mode, the ownership model preserves the developer's ability to work with the repository: - **Project directory**: Owned by the repo owner (e.g., `wall`), group is the repo owner's primary group (e.g., `wall`). The developer retains full control — `git add`, `git commit`, editing code and config files all work normally. -- **Daemon access**: The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group. +- **Daemon access**: The daemon user (`walld`, i.e. `${USER_NAME}d`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group. - **`.venv/` and `data/`**: Owned by the repo owner, group is the repo owner's primary group. The developer can run `pip install`, inspect logs, and manage runtime artifacts. The daemon reads `.venv/` (Python interpreter) and writes to `data/` (runtime files) via group permissions. -- **Daemon socket** (`data/daemon.sock`): Owned by `vacuum-walld:` (mode `0660`). The repo owner accesses it via primary group membership. +- **Daemon socket** (`data/daemon.sock`): Owned by `walld:` (mode `0660`). The repo owner accesses it via primary group membership. ### Running the Installer in Dev Mode @@ -74,7 +74,7 @@ In dev mode, the ownership model preserves the developer's ability to work with ./scripts/install.sh --dev --mgmt-pass strongpassword ``` -The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above. +The script detects the repo owner (e.g., `wall`), creates the `walld` daemon user (`${USER_NAME}d`) with the repo owner's primary group, and sets up the ownership model described above. ### Idempotent Re-Runs @@ -92,7 +92,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o --mgmt-domain proxy.internal --mgmt-pass strongpassword ``` -The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `INSTALL_DIR`. This means no hardcoded paths remain after installation. +The systemd `.service` unit files and the sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME`, `USER_DAEMON_NAME`, `USER_GROUP`, `PROJECT_DIR`, and `ACME_HOME`. This means no hardcoded paths remain after installation. --- @@ -103,29 +103,35 @@ The installer performs the following steps automatically: - **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils. - **WebUI user creation**: Creates the WebUI user (from `--user`) as a system user if it does not exist. - **Shared group**: Uses the WebUI user's primary group as the shared group between both service users. -- **Daemon user creation**: Creates `vacuum-walld` (derived from WebUI user name) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the project directory and daemon socket. +- **Daemon user creation**: Creates the daemon user `${USER_NAME}d` (the literal `vacuum-walld` only when the WebUI user is `vacuum-wall`) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the daemon socket and, outside `--dev` mode, the project directory (in dev mode the repo owner keeps project ownership). - **Python venv**: Creates the Python virtual environment and installs project dependencies. Skips if already present (use `--force-venv` to recreate). -- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed. +- **acme.sh installation**: Fetches the vendored acme.sh (via `scripts/update-vendor.sh`) and installs it to `data/acme/acme.sh`, skipping if it is already present. Also installs the `system/acme-deploy.sh` deploy hook into `data/acme/deploy/acme-deploy.sh` (acme.sh only resolves hooks from its own deploy directory) and repairs ownership of the acme.sh runtime conf files under `data/acme/` — including `account.conf`, which is chmodded to `0640` — to the daemon user, so the first acme.sh run cannot fail on owner-only files. - **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config). -- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values. -- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`. +- **System-directory ownership repair**: Checks top-level system directories (`/`, `/bin`, `/boot`, `/etc`, `/home`, `/opt`, `/root`, `/srv`, `/usr`, `/var`, …) for non-root ownership — some appliance images ship with system paths owned by a regular user, which trips systemd-tmpfiles' "unsafe path transition" check. Mis-owned top-level directories are chown'd to `root:root`; if deeper mis-ownership is detected, the installer warns with a full-repair command to run before re-running. +- **Static-asset permissions**: `chmod a+rX` on `webui/static/` (plus `a+x` up the parent directory chain) so nginx's `www-data` workers can serve the management UI's static assets directly from disk, regardless of checkout umask. +- **Template rendering**: Renders the systemd `.service` files and the sudoers whitelist via Jinja2, substituting `USER_NAME`, `USER_DAEMON_NAME`, `USER_GROUP`, `PROJECT_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values. +- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed: firewalld management (`firewall-cmd`), nginx (config test/reload, copying/removing the generated conf files), dnsmasq (restart, lease-file reads, fragment install), WireGuard (`wg`, `wg-quick`, installing `wg0.conf`), network interface queries (`ip -o link/addr show`), systemd-networkd (`networkctl` status/reload/reconfigure, managing `/etc/systemd/network`), sysctl writes, group-permission repair on the ACME home, and journal/log reads (`journalctl`, `cat /var/log/nginx/*`). Validates syntax with `visudo -cf`. - **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present. - **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access. - **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces. - **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`.local`) on the local network. -- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs). -- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`. -- **Admin user**: Creates the admin user with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`). The user gets `rw` permissions on all subsystems. On re-run, updates the admin password if already present. -- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists). +- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain via `POST /acme/self-signed` (CN set to the domain), written to `data/certs/.crt` and `data/certs/.key` — not under `data/acme/`, where acme.sh stores issued certs. Idempotent: skips generation when both files already exist. +- **Management proxy configuration**: Registers the management domain via `POST /nginx/domains/update` (falling back to `POST /nginx/domains/add`) as the special built-in `webui` backend entry (cert `selfsigned`, forced SSL). The `/` → 127.0.0.1:9090 (Flask) and `/ws` → 127.0.0.1:9091 (daemon WebSocket) mapping is derived by the daemon from the built-in webui backend — it is not passed as paths config. Then applies nginx via `POST /nginx/apply`. +- **Admin user**: Creates the admin user (default username `admin`) with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`), with `rw` permissions on all subsystems, and writes `config/auth/config.json` (JWT + WebAuthn settings) if missing. On re-run, updates the admin password if already present. The bootstrap runs with `VACUUM_WALL_SEED_BUILTIN_ADMIN=0`, suppressing the last-resort builtin admin seed so exactly one account exists on a fresh install. +- **Initial configs**: Once the daemon socket is up, the installer writes initial state over the daemon API: `POST /acme/self-signed` (management cert), the management domain plus `POST /nginx/apply`, and firewall zone assignment — `POST /firewall/zones/interfaces` (WAN interface → `public`) and `POST /firewall/zones/services` (http/https/ssh on `public`) when a WAN interface was detected, and `POST /firewall/zones/interfaces` (LAN interfaces → `internal`) when LAN interfaces were detected. These writes are not skipped; the only skip-if-exists rule applies to the auth config (see **Admin user**). - **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually. -- **Systemd units**: Installs four units (rendered from Jinja2 templates): +- **Systemd units**: Installs four units — three `.service` files are rendered from Jinja2 templates; the `.timer` is installed verbatim: - `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket). - `vacuum-wall.service` — the Flask WebUI backend. - `vacuum-wall-acme.service` — the certificate renewal oneshot. - - `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals. -- **Firewalld zones**: Creates initial zones: - - `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed. - - `vpn` — WireGuard tunnel zone. + - `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals (no template variables). + + A fifth file, `system/tmpfiles.d/vacuum-wall.conf`, is installed to `/etc/tmpfiles.d/vacuum-wall.conf` and `systemd-tmpfiles --create` is run immediately — load-bearing for the hardened unit: it provisions the volatile `/run` entries the daemon needs before `vacuum-walld` spawns (restored at every boot by `systemd-tmpfiles-setup.service`). +- **Firewalld zones**: Assigns initial zones via the daemon API (the installer does not create zones directly): + - `public` — the WAN interface is assigned here and the `http`, `https`, and `ssh` services are opened for management access (only when a WAN interface was detected). + - `internal` — the LAN interfaces are assigned here (only when LAN interfaces were detected); no services are added at install time. + - `vpn` — **not** created by the installer. It is managed dynamically by `lib/sync.py` only while WireGuard peers exist (interface assignment, masquerade, and rich rules), and is cleaned up again when WireGuard is deactivated. +- **Legacy nginx config cleanup**: Removes the old nginx bootstrap configs (`/etc/nginx/conf.d/vacuum-wall-map.conf` and `/etc/nginx/conf.d/vacuum-wall-mgmt.conf`), which are replaced by the daemon-generated nginx configuration. - **Service startup**: Enables and starts/restarts nginx, the daemon (`vacuum-walld`), the WebUI (`vacuum-wall`), and the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes. - **ACME account**: No account registration during install. Register the account via the WebUI after first login. @@ -136,7 +142,7 @@ The installer performs the following steps automatically: - Skips the Python venv (use `--force-venv` to rebuild) - Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes - Preserves existing SSL certificates (skips self-signed generation if a cert exists) -- Preserves existing `config.json` files (skips initial write if file exists) +- Preserves the existing auth config (`config/auth/config.json` is only written if missing — the only skip-if-exists config rule) - Updates admin user password if changed This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation. @@ -171,6 +177,13 @@ Log in with the username and password you provided during installation. |---|---|---| | `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection | | `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path | +| `VACUUM_WALLD_SOCKET` | `data/daemon.sock` | Daemon Unix socket path (`daemon/server.py:669`) | +| `VACUUM_WALLD_WS_PORT` | `9091` | Daemon WebSocket port on `127.0.0.1` for real-time state streaming (`daemon/server.py:31`) | +| `VACUUM_WALL_POLL_INTERVALS` | built-in per-subsystem defaults | Comma-separated `subsystem:seconds` overrides for the state-poll intervals, e.g. `firewall:60,wireguard:5`; non-integer or ≤ 0 values are skipped with a warning (`daemon/server.py:34`) | +| `VACUUM_WALL_DEV` | unset (off) | Dev-mode flag: disables aggressive static-asset caching in the WebUI (`webui/server.py:86`) | +| `VACUUM_WALL_LOG_LEVEL` | `INFO` | Log level for the WebUI and daemon processes (`lib/logging.py:49`) | +| `VACUUM_WALL_EXTERNAL_IP_URL` | built-in detection | Custom URL for external-IP detection used by ACME (`daemon/handlers/acme.py:285`) | +| `VACUUM_WALL_SEED_BUILTIN_ADMIN` | `1` | Set to `0` to skip the last-resort builtin admin seed in `get_db()`; `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself (`lib/db.py:307`) | ### Post-Deploy Verification @@ -297,7 +310,9 @@ journalctl -u vacuum-wall --no-pager -n 50 nginx -t ``` -Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `data/`. +Both units also keep journal output on disk under `/var/log/vacuum-wall/` (`LogsDirectory=vacuum-wall` on both units). Nginx writes per-domain access/error logs to `/var/log/nginx/wall_mgmt_*.log` for the management domain and `/var/log/nginx/_*.log` for each proxy domain. + +Common causes include port conflicts (another service on port 80/443, 9090, or 9091 — the daemon's WebSocket port), missing dependencies, or file permission issues on `data/`. ### Firewall Rules Not Applying @@ -369,16 +384,17 @@ If the SQLite database becomes corrupted: 1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld` 2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"` -3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db` -4. Start services: `sudo systemctl start vacuum-walld vacuum-wall` +3. If the file is unrecoverable, delete it (`rm data/auth.db`) and start the services: `sudo systemctl start vacuum-walld vacuum-wall`. The schema is recreated on startup; if the users table is empty, the last-resort builtin admin is seeded with a random password written to `/var/log/vacuum-wall/auth.log`. +4. Re-set the password via the WebUI, or use the SQLite steps under "Locked Out of WebUI". ### WebUI Not Accessible 1. Verify nginx is running: `systemctl status nginx`. 2. Test nginx configuration: `nginx -t`. 3. Check the management proxy domain configuration via the WebUI Proxy tab, or by inspecting `config/nginx/config.json`. -4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`. -5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate. +4. Ensure the daemon is running and its Unix socket exists: `systemctl status vacuum-walld` and `ls -l data/daemon.sock` — the WebUI proxies every API call through this socket. +5. Ensure the WebUI service is listening on port 9090 (`ss -tlnp | grep 9090`) and the daemon's WebSocket endpoint on port 9091 (`ss -tlnp | grep 9091`). +6. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate. --- diff --git a/docs/hoover.md b/docs/hoover.md index ddc16fe..ed9e334 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -8,18 +8,25 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p |---|---|---| | Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests | | VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching | +| HTM | `html.js` | `htm` binding of `vdom.js`'s `htmAdapter` — the `html` tagged-template tag | | 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: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), 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: 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 | +| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting | +| Schema | `schema.js` | Per-subsystem state defaults (`SUBSYSTEMS`) and client-side poll cadence (`POLL_INTERVALS`) | +| Dirty markers | `dirty.js` | Pending-edit (not-yet-applied) UI markers: hash-subsystem and firewall variants | +| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts, auth ceremony, QR | | Barrel | `index.js` | Single import point for all public APIs | -All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point. +All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from +this single entry point, with two exceptions: `pages/certs.js` and `pages/backends.js` +also import directly from `hoover/components/modal.js` (`isModalProcessing`, +`setModalProcessing`, `refreshModals`) and `pages/backends.js` imports `_deleting` from +`hoover/components/data.js`. ## Architecture @@ -42,11 +49,11 @@ Each render root registers a render function via `render(container, fn)`. When r ``` 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() + (snapshot on connect, versions/tick deltas per subsystem) +HTTP fallback (one-shot 3s initial-load timer) → 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()`. +The **model layer** is the single source of truth for subsystem data. Model-backed pages call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. (Two pages — `users.js` and `passkeys.js — fetch page-local data with `apiFetch` in `load()` against a module-level reactive state instead of a registered model; see **Module-level shared reactive state** below.) 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`). @@ -57,12 +64,18 @@ Mutations no longer trigger explicit model refreshes: after a successful write t The app starts from `webui/static/app.js`: ```javascript -import { h, render, Link, hComp, ToastContainer, connect, apiFetch, - modelRegister, modelFetch, reactive } 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'; -// 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). +// 1a. Auth model — registered first. Silent topic: the daemon never +// broadcasts 'auth', so refreshByTopic() can never fetch it. +modelRegister('auth', createAuthModel()); + +// 1b. 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' }, @@ -89,14 +102,11 @@ for (const { name, subsystem } of STATE_MODELS) { }); } modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } }); -modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } }); - -```javascript -// ... more modelRegister calls ... +modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } }); // 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. +// snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP) +// if it hasn't arrived. Non-state models fetch immediately. function fetchInitialData() { for (const { name } of STATE_MODELS) { setTimeout(() => { @@ -108,29 +118,59 @@ function fetchInitialData() { modelFetch('logs', 'journal'); } -// 3. Create reactive router state +// 3. Custom router — reactive path state plus the auth guard (see Router below) const router = { state: reactive({ path: location.hash.slice(1) || '/dashboard' }), component() { - const name = this.state.path.replace(/^\//, ''); + const { path } = this.state; + if (path !== '/login' && !isAuthenticated()) { + return hComp(LoginPage, '/login'); + } + const name = path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; - return hComp(page, this.state.path); + return hComp(page, path); }, }; -// 4. Listen for hash changes -window.addEventListener('hashchange', () => { - router.state.path = location.hash.slice(1) || '/dashboard'; -}); +// 4. Init: session check before mounting, listeners, conditional boot +export async function initApp() { + // auth:login — (deferred to a macrotask so the login form's hashchange + // has landed) give the post-login session its WS and fetch all models. + window.addEventListener('auth:login', () => { + setTimeout(() => { + connect(); + if (!router.state.path.startsWith('/login')) fetchInitialData(); + }, 0); + }); + // auth:logout (terminal transition) — tear down the WS socket. + window.addEventListener('auth:logout', () => disconnect()); -// 5. Mount render roots -render(sidebarEl, Sidebar); -render(mainEl, MainContent); + // Check the session BEFORE mounting the shell: an unauthenticated + // visitor must never flash the sidebar or a protected page. + await modelFetch('auth', { action: 'check' }); + authChecked = true; + if (isAuthenticated()) { + if (router.state.path === '/login') window.location.hash = '/dashboard'; + fetchInitialData(); + setTimeout(connect, 0); // WS only for authenticated sessions + } else if (router.state.path !== '/login') { + window.location.hash = '/login'; + } -// 6. Start WebSocket (deferred to avoid initial render conflict) -setTimeout(connect, 0); + // Mount render roots (Sidebar renders null when unauthenticated) + render(sidebarEl, Sidebar); + render(mainEl, MainContent); +} ``` +Bootstrap order matters: the auth model is registered first, then the +bootstrap session check (`modelFetch('auth', { action: 'check' })`) is +**awaited before the render roots mount** so an unauthenticated visitor is +redirected to `#/login` before first paint. `connect()` is conditional — +it runs only for an authenticated session (also from the `auth:login` +listener after a fresh login). `disconnect()` is wired to the terminal +`auth:logout` event (see **Auth model**). + ## Reactivity ### `reactive(obj)` @@ -147,7 +187,7 @@ state.data = result; Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates. -**Important:** Hoover's reactivity proxy intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. Always mutate top-level properties by assignment: +**Important:** Hoover's reactivity proxy tracks property **assignment only** (the Proxy `set` trap). Adding a new top-level property is an assignment, so it *does* trigger a re-render. Deletions (`delete state.x`) are **not** tracked — there is no `deleteProperty` trap — and neither are array mutations (`push`, `splice`) or nested object changes (nested objects are plain, not wrapped). Always mutate top-level properties by assignment: ```javascript // Correct — assigns a new array @@ -231,9 +271,9 @@ render(state) { } ``` -### `modelFetch(name, signal?, param?)` +### `modelFetch(name, signalOrParam, signal)` -Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. +Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. The **second argument is the param** (e.g., a tab key or the auth model's `{ action }` object); an `AbortSignal` is accepted there for backward compatibility, and a param-carrying call passes the signal as the **third** argument (`modelFetch('logs', 'journal')`, `modelFetch('auth', { action: 'refresh' })`). ```javascript // HTTP fallback for a state-backed model (WS snapshot is the primary path; @@ -256,7 +296,7 @@ modelFetch('logs', 'nginx-access'); **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. +- Sets `model.loading = true` when the model is still in its initial state (`loading` set and `data === null`), otherwise `model.refreshing = true`. - Clears `model.error` before fetch. - On success, assigns result to `model.data`. - On failure, stores error in `model.error`. @@ -285,11 +325,12 @@ modelSet('firewall', payload); // payload: the subsystem state object `null` payload (a failed collector keeps the current data). See **WS Message Types** / **WS Data Streaming Flow** below. -### `refreshByTopic(topic)` +### `refreshByTopic(topic)` — internal, not exported from the barrel -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). +Refresh all models whose subsystem topic matches via `modelFetch()`. +**Not re-exported from `hoover/index.js` and never called anywhere** — +`websocket.js` delivers data via `modelSet` instead. It exists in `model.js` +only as an internal / legacy utility; do not rely on it. | Model `subsystem` | Topic | Match? | |---|---|---| @@ -320,7 +361,8 @@ Returns `{ loading, refreshing, error }` derived from the union of all passed mo `auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted to the single source of truth for the token/session lifecycle: token storage (sessionStorage via internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL − 60s -timer, driven by the token's `exp` claim), session validation, login/logout transitions, and WS +timer with a **30s minimum delay** — `Math.max(ttl − 60000, 30000)` — driven by the token's `exp` +claim), session validation, login/logout transitions, and WS reconnection coordination. Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()` @@ -341,15 +383,17 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t app bootstrap → modelFetch('auth', { action: 'check' }) → 200: stores verified user/permissions + stored tokens → schedules the refresh at the token's REMAINING lifetime (exp claim, not the full issued - TTL) minus 60s - → 401 with a stored refresh token (stale access token after page - reload/restore): exactly one refresh attempt, then the same - success or terminal path + TTL) minus 60s (minimum 30s) + → non-2xx response (e.g. 401) with a stored refresh token (stale access + token after page reload/restore): exactly one refresh attempt, then the + same success or terminal path (no auth:login — initApp() calls fetchInitialData()/connect() directly) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) → onSuccess stores rotated tokens (new session_id) or clears + redirects (no auth:login dispatch) -timer fires (remaining TTL − 60s) → refreshAuth() → same path +timer fires (remaining TTL − 60s, min 30s) + → modelFetch('auth', { action: 'refresh' }) under the module-level + `_refreshing` guard (skipped if one is already in flight) → same path WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection) login → modelFetch('auth', { action: 'login', payload: data }) → onSuccess stores + schedules + fires auth:login (login action only) @@ -364,8 +408,8 @@ any terminal no-token result → onSuccess dispatches auth:logout - **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it (collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd, system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven - by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback - (exactly one refresh when the stored access token is rejected at page load while a + by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback + (exactly one refresh when the session check gets a non-OK response at page load while a refresh token is still present). - **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`. - **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model @@ -379,8 +423,10 @@ any terminal no-token result → onSuccess dispatches auth:logout (app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js` (would cycle) — the event inverts the dependency. - **Session binding rotation** — the server mints a new `session_id` on every refresh; any - post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both** - `Authorization` and `X-Session-Id` from `getAuthData()`. + post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must + re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. The WS handshake + is different: it sends **only the token** as the `Sec-WebSocket-Protocol` subprotocol — + `X-Session-Id` is an HTTP-only header and plays no part in the socket handshake. - **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object: `name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant @@ -411,11 +457,20 @@ h('div', { class: 'card' }, h('span', null, 'Hello')) // Text node h('#text', 'some text') -// Component (Hoover component, not function — must use hComp or h('#comp', ...)) +// Function component — `h()` calls the function directly with the props +// (children merged into `props.children`): the function's return value +// (a VNode) is the result. All the UI components (Badge, Card, …) are +// used this way. +h(Badge, { text: 'OK', variant: 'success' }) + +// Lifecycle component (page) — opaque #comp vnode, NOT called by h(): +// managed by the render engine's mount/unmount lifecycle h('#comp', { component: MyPage, key: '/dashboard' }, []) ``` -**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes. +The `html` tagged-template adapter uses the same function-component path: `<${Badge} … />` compiles to `htmAdapter(Badge, props, …children)`, which forwards to `h()`. + +**Children flattening:** children are flattened recursively (`arr.flat(Infinity)` — nested arrays are inlined). `null`, `undefined`, and **all booleans (including `true`)** children are filtered out. String and number primitives are automatically converted to text VNodes. ### HTM (Tagged HTML Templates) @@ -481,15 +536,16 @@ html`<${Badge} ...${badgeProps} />` | `value` | On ``, `