Compare commits

...

5 Commits

Author SHA1 Message Date
mteehan b503a6dcf0 docs: full refresh per DOCSPLAN (auth subsystem, backends model, access classes, sudo table, state-model mechanics) + 3 stale docstrings 2026-09-05 16:34:57 +00:00
mteehan 78fcb01877 fix: install.sh loop abort, ACME poll sudo gate, /static/ sub-paths
- install.sh: the traversal-chmod loop assigned _d but looped over the
  never-set $d; under set -u every fresh install aborted with
  "d: unbound variable" at that line. Loop over $_d.
- acme collector: the self-heal normalize (sudo chmod g+rwX) now runs
  only when a no-sudo group-read-bit probe detects a lost bit — acme.sh
  re-hardens the tree 600 on every run, so the steady-state poll makes
  no sudo call. The group bit (not daemon readability) is what the
  two-user model keeps for the WebUI user.
- lib.acme: new get_acme_home() accessor (ACME_HOME env, default
  data/acme), reused by _run_acme; _summarize_acme_output preserves a
  "Permission denied" line even when it is not among the final two, so
  the collector's actionable-error matcher keeps firing.
- nginx template: emit location /static/ for any is_management path
  (not only '/'); the SPA references /static/... at the domain root
  regardless of the management backend path.
- tests: probe, summarizer, and nginx-subpath cases in
  test_state.py, test_acme.py, test_nginx.py.
2026-09-05 00:38:34 +00:00
mteehan 6229c39347 ui: declarative per-page tab titles via definePage title 2026-09-04 21:59:09 +00:00
mteehan 6476695d29 fix: ACME cert list self-heals when account.conf is left owner-only
The startup normalize and _run_acme_preflight covered daemon startup and issue/renew, but the recurring collector poll called lib.acme.list_certs() without normalizing ACME_HOME. A non-daemon run (e.g. a manual run as the WebUI user) re-creating account.conf owner-only made every acme.sh --list exit 2, so the collector returned certs=[] and the UI showed no certs until the next issue/renew or daemon restart.

- collector: normalize_acme_home() before list_certs() so the poll self-heals
- issue pre-check: normalize before the direct lib.acme.list_certs()
- _parse_account_conf: read acme.sh v3 account.conf (not just .account.conf)
- _collect_acme: actionable status.error for the account.conf perm case
- install.sh: chown ACME_HOME conf files to the daemon user
2026-09-04 21:38:55 +00:00
mteehan 2b7fe1f485 ui: per-container #comp lifecycle, exp-claim auth refresh TTL
- hoover: #comp registry + expanded-content cache now per render
  container; committing one root no longer unmounts/remounts
  components owned by another root (infinite load loop on pages
  whose load() re-mutates reactive state)
- auth_model: refresh timer scheduled from the token's remaining
  exp claim (unverified decode, mirrors lib/auth.py); falls back to
  the configured TTL for non-JWT/malformed/already-expired tokens
- docs: hoover.md documents both behaviors
- tests: exp-claim TTL cases in test-auth-model.js; new
  test-render-lifecycle.js regression suite
2026-09-03 17:25:22 +00:00
41 changed files with 2331 additions and 588 deletions
+2 -1
View File
@@ -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/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — | | `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — | | `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
| `webui/api/auth` | `/api/auth/` | `daemon/handlers/auth` | `lib.auth` / `lib.auth_users` |
## Privileged Operations ## 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`. **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 ```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint .venv/bin/ruff check lib/ webui/ tests/ # lint
+21 -13
View File
@@ -7,7 +7,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
- Debian 13 (trixie) target platform - Debian 13 (trixie) target platform
- Python 3.13+, Flask 3.x web UI - Python 3.13+, Flask 3.x web UI
- firewalld (nftables backend), dnsmasq, nginx, WireGuard - 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 | | Flag | Env Var | Required | Description |
|---|---|---|---| |---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) | | -- | `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`) | | `--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`) | | `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) |
| `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) | | `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) |
| `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning | | `--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 .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 ### Documentation MCP Server
@@ -115,17 +115,23 @@ Then run with Claude Code or Opencode to activate it. It automatically checks li
### Architecture ### Architecture
``` ```
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090) Client ──→ nginx (TLS; basic auth on basic-authed proxy domains only) ──→ Flask (127.0.0.1:9090)
Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld ──→ handlers ──→ sudo
``` ```
| Blueprint | URL prefix | Backend module | Blueprints are thin proxies — privileged handlers live in `daemon/handlers/*.py`.
|---|---|---|
| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` | | Blueprint | URL prefix | Handler | lib module |
| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` | |---|---|---|---|
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` | | `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
| `webui/api/certs` | `/api/certs/` | `lib.acme` | | `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` | | `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. 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 - [API Reference](docs/api.md) — REST API endpoints
- [Security Model](docs/security.md) — Privilege model and sudo whitelist - [Security Model](docs/security.md) — Privilege model and sudo whitelist
- [Configuration](docs/config.md) — Declarative config file formats and locations - [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
+64 -4
View File
@@ -3,9 +3,11 @@
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from stat import S_IRGRP
from typing import Any from typing import Any
from lib import schema from lib import schema
from lib.acme import get_acme_home
from lib.common import load_json from lib.common import load_json
from lib.state import PROJECT_DIR, _now_iso, register_collector from lib.state import PROJECT_DIR, _now_iso, register_collector
@@ -66,9 +68,17 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"key_length": None, "key_length": None,
} }
# 1. Legacy .account.conf (acme.sh v2.x) # 1. acme.sh account file. Modern acme.sh (v3.x) writes ``account.conf``;
account_path = acme_home / ".account.conf" # older v2.x wrote ``.account.conf``. Check both so the account card
if account_path.is_file(): # reflects the real acme.sh account rather than only the declarative
# fallback below.
account_path = None
for name in ("account.conf", ".account.conf"):
candidate = acme_home / name
if candidate.is_file():
account_path = candidate
break
if account_path is not None:
try: try:
text = account_path.read_text() text = account_path.read_text()
except OSError: except OSError:
@@ -129,6 +139,45 @@ def _get_acme_email() -> str:
return _read_acme_email() return _read_acme_email()
def _friendly_acme_error(exc: Exception) -> str:
"""Turn a collection exception into an actionable message.
The collector already self-heals by normalizing ACME_HOME permissions
first, so the one remaining permission case is when that normalize could
not run (e.g. the sudo step was denied). For that case surface a concrete
remediation instead of the raw acme.sh exit-2 text; otherwise return the
original message unchanged.
"""
text = str(exc)
if "account.conf" in text and "Permission denied" in text:
return (
f"{text} — account.conf is not readable by the daemon; repair it "
"with: sudo chown <daemon-user>:<group> <ACME_HOME>/account.conf "
"&& sudo chmod 0640 <ACME_HOME>/account.conf, then restart "
"vacuum-walld"
)
return text
def _acme_home_needs_normalize() -> bool:
"""Cheap no-sudo probe: has any ACME_HOME file lost its group-read bit?
acme.sh re-hardens its tree (``chmod 600``) on every run, so the daemon's
self-heal (``normalize_acme_home``) is only needed after a run by another
user (e.g. a manual run as the WebUI user) stripped group read. The probe
checks the group bit — not the daemon's own readability — because group
read is what the two-user model keeps for the WebUI user; a file the
daemon can read but the group cannot must still be healed.
"""
try:
for p in get_acme_home().rglob("*"):
if p.is_file() and not (p.stat().st_mode & S_IRGRP):
return True
except OSError:
return True
return False
def _collect_acme() -> schema.AcmeState: def _collect_acme() -> schema.AcmeState:
"""Collect ACME certificate list and email. """Collect ACME certificate list and email.
@@ -143,13 +192,24 @@ def _collect_acme() -> schema.AcmeState:
# `status.error` so the poll diff still detects recovery. # `status.error` so the poll diff still detects recovery.
cert_error: str | None = None cert_error: str | None = None
try: try:
# Self-heal ACME_HOME permissions before listing, but only when the
# probe detects a lost group-read bit — the steady-state poll then
# makes no sudo call. acme.sh dot-sources account.conf on startup; a
# prior run by another user (e.g. a manual run as the WebUI user) can
# leave it owner-only and make `--list` exit 2. The startup normalize
# only covers the first collection, so the poll must probe too or a
# mid-lifetime ownership flip would blank the cert list until the
# next issue/renew or daemon restart.
from daemon.handlers.acme import normalize_acme_home
from lib.acme import list_certs from lib.acme import list_certs
if _acme_home_needs_normalize():
normalize_acme_home()
certs = list_certs() certs = list_certs()
except Exception as exc: except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True) logger.warning("ACME state collection failed", exc_info=True)
certs = [] certs = []
cert_error = str(exc) cert_error = _friendly_acme_error(exc)
account = _parse_account_conf() account = _parse_account_conf()
+4 -1
View File
@@ -807,8 +807,11 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
"status": "existing", "status": "existing",
} }
# Check if cert already exists — call acme.sh directly, not via state # Check if cert already exists — call acme.sh directly, not via state.
# Normalize ACME_HOME first (same reason as the preflight): a prior run by
# another user can leave account.conf owner-only and make `--list` exit 2.
try: try:
normalize_acme_home()
certs = lib.acme.list_certs() certs = lib.acme.list_certs()
except RuntimeError as exc: except RuntimeError as exc:
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc
+348 -123
View File
@@ -1,6 +1,6 @@
# REST API Reference # 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 <token>` 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 <token>` 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 <token>` 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`. 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 ### 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 ## Conventions
@@ -46,6 +46,8 @@ Error responses carry one of the following HTTP status codes:
| Code | Meaning | | Code | Meaning |
|------|---------| |------|---------|
| `400` | Bad request — invalid body, missing required field, or malformed value | | `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 | | `404` | Not found — the requested resource does not exist |
| `409` | Conflict — the requested operation conflicts with an existing resource | | `409` | Conflict — the requested operation conflicts with an existing resource |
| `500` | Internal server error — unexpected failure in the backend | | `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. **Auth:** Access token required.
**Response:** `data` is `null` on success. **Response:** `data` is `{}` (an empty object) on success.
#### Refresh Tokens #### Refresh Tokens
@@ -131,9 +133,16 @@ Invalidate the current session by blacklisting the access token.
POST /api/auth/refresh 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`):** **Response (`data`):**
@@ -162,11 +171,11 @@ Change the current user's password.
|---|---|---|---| |---|---|---|---|
| `username` | `string` | No | Auto-injected from JWT context | | `username` | `string` | No | Auto-injected from JWT context |
| `oldPassword` | `string` | Yes | Current password | | `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 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`):** **Response (`data`):** the array of user summaries directly (no `users` wrapper). Each entry has `id`, `username`, `permissions` (`{ subsystem: "read" | "rw" }`), and `created_at`.
| Field | Type | Description |
|---|---|---|
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
#### Create User #### Create User
@@ -203,7 +208,7 @@ Create a new user with password and per-subsystem permissions.
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `username` | `string` | Yes | Username | | `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" }`) | | `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response (`data`):** **Response (`data`):**
@@ -214,7 +219,7 @@ Create a new user with password and per-subsystem permissions.
| `username` | `string` | Username | | `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | | `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 #### Update User
@@ -254,12 +259,37 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Response:** `data` is `{"ok": true}` on success. **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 ### 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": "<reason>"}`.
---
#### Begin Registration #### Begin Registration
``` ```
@@ -274,9 +304,9 @@ Start WebAuthn credential registration. Returns options for `navigator.credentia
| Field | Type | Required | Description | | 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 | | Field | Type | Description |
|---|---|---| |---|---|---|
@@ -299,13 +329,19 @@ Complete WebAuthn credential registration. Verifies the attestation response and
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `username` | `string` | Yes | Username | | `username` | `string` | No | Auto-injected from the JWT; any value in the body is overridden |
| `response` | `object` | Yes | WebAuthn authenticator attestation response | | `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 | | `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 #### Begin Authentication
@@ -359,7 +395,7 @@ Complete WebAuthn authentication. Verifies the assertion and issues tokens on su
| `user` | `object` | User info (`username`, `id`) | | `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions | | `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `400` if verification fails. Returns HTTP `401` if verification fails or required fields are missing.
#### List Credentials #### List Credentials
@@ -373,7 +409,7 @@ List WebAuthn credentials for the current user.
**Response (`data`):** **Response (`data`):**
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`). Array of credential objects (`id`, `name`, `transports`, `sign_count`).
#### Credential Counts #### Credential Counts
@@ -381,15 +417,11 @@ Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCo
GET /api/auth/webauthn/credential-counts 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`):** **Response (`data`):** The dict directly (no `counts` wrapper) — a mapping of usernames to credential counts (`{"alice": 2, "bob": 1}`).
| Field | Type | Description |
|---|---|---|
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
#### Remove Credential #### Remove Credential
@@ -401,9 +433,9 @@ Remove a WebAuthn credential.
**Auth:** Access token required. **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. 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`. **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 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 #### Partial Update Config
@@ -528,13 +569,18 @@ Return detailed configuration for a single zone.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `name` | `string` | Zone name |
| `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) | | `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) |
| `interfaces` | `[string, ...]` | Interfaces assigned to this zone | | `interfaces` | `[string, ...]` | Interfaces assigned to this zone |
| `sources` | `[string, ...]` | Source IPs addressed by this zone |
| `services` | `[string, ...]` | Services allowed through the zone | | `services` | `[string, ...]` | Services allowed through the zone |
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) | | `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 | | `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules | | `ics` | `boolean` | Whether ICMP redirect (ICS) is enabled |
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs | | `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. 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 | | `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned | | `interfaces` | `[string, ...]` | List of interface names now assigned |
Returns HTTP `404` if the zone does not exist.
--- ---
#### Set Zone Services #### Set Zone Services
@@ -611,6 +659,7 @@ Replace all services allowed in the zone with the provided list.
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names | | `services` | `[string, ...]` | Yes | List of firewalld service names |
| `force` | `boolean` | No | Override the management-lockout guard |
**Response (`data`):** **Response (`data`):**
@@ -619,6 +668,8 @@ Replace all services allowed in the zone with the provided list.
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `services` | `[string, ...]` | List of services now allowed | | `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 ### Rich Rules
#### Add Rich Rule #### Add Rich Rule
@@ -671,13 +722,13 @@ Returns HTTP `404` if the rule ID is not found.
GET /api/firewall/rich-rules/<zone> GET /api/firewall/rich-rules/<zone>
``` ```
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:** **Response:**
| Field | Type | Description | | 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 ### Port Forwarding
@@ -752,6 +803,8 @@ Toggle masquerade (source NAT) for a zone.
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled | | `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 ### State
#### Get Firewall State #### Get Firewall State
@@ -866,7 +919,7 @@ Deep-merge the provided fields into the existing configuration. Useful for targe
POST /api/dhcp/apply 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. **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 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`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `service_active` | `boolean` | Whether dnsmasq is running | | `service_active` | `boolean` | Whether dnsmasq is running |
| `config_file_exists` | `boolean` | Whether config file exists on disk | | `config_file_exists` | `boolean` | Whether the 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 |
| `active_leases` | `number` | Number of active leases | | `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 ### DHCP Ranges
@@ -930,12 +978,14 @@ Remove a DHCP range. Body contains identifying fields.
| Field | Type | Required | Description | | 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 | | `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range | | `end` | `string` | Yes | End of IP range |
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
Returns HTTP `404` if no range matches the given interface/start/end.
### Static Leases ### Static Leases
#### Add Static Lease #### 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 ## Proxy API
Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy. 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 | | `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 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 | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy | | `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 | | `cert` | `string` | No | Certificate type |
| `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) | | `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) |
| `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: <path>}` is stored) |
**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 |
**Response (`data`):** **Response (`data`):**
@@ -1160,7 +1220,7 @@ Add a new reverse proxy domain. Accepts two modes:
|-------|------|-------------| |-------|------|-------------|
| `domain` | `string` | Domain name | | `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/<domain> PUT /api/proxy/domains/<domain>
``` ```
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`):** **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 | | `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. 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": "<name>"}`.
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": "<name>"}`.
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/<name>
```
Remove a non-builtin backend.
**Response (`data`):** `{"backend": "<name>"}`.
Returns HTTP `409` if one or more domains reference the backend. Returns HTTP `400` if the backend is built-in.
---
### Apply / Test ### Apply / Test
#### Apply Configuration #### Apply Configuration
@@ -1259,7 +1404,7 @@ Return all managed certificates with metadata.
|-------|------|-------------| |-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects | | `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/<domain> GET /api/certs/<domain>
``` ```
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": <domain>, "status": "issuing", "issuance": {...}}`.
Returns HTTP `404` if no certificate is found and no issuance is in progress.
### Validation ### Validation
@@ -1318,6 +1465,8 @@ Create a new certificate issuance request. Issuance runs asynchronously in the b
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling issuance status | | `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). 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 runs in the background and is polled via
`GET /api/certs/renew/<request_id>`. `GET /api/certs/renew/<request_id>`.
**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`):** **Response (`data`):**
@@ -1392,11 +1545,11 @@ is skipped, or fails.
DELETE /api/certs/<domain> DELETE /api/certs/<domain>
``` ```
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. **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 ### Account
@@ -1453,7 +1606,7 @@ Returns HTTP `400` if the email is missing or invalid. Returns HTTP `500` if reg
DELETE /api/certs/account 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`):** **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 | | `email` | `string` | Empty string indicating the account was deactivated |
Returns HTTP `500` if deactivation fails.
--- ---
#### Set ACME Contact Email #### Set ACME Contact Email
@@ -1481,13 +1632,11 @@ Set or update the ACME account contact email.
**Response (`data`):** Returns the set `email` field. **Response (`data`):** Returns the set `email` field.
#### Generate Self-Signed Certificate #### Generate Self-Signed Certificate (daemon-only)
``` 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).
POST /api/certs/self-signed
```
Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `<domain>.key` already exist at `data/acme/<domain>/`. It generates a self-signed certificate for a domain and is idempotent — it skips generation if `<domain>.crt` and `<domain>.key` already exist at `data/certs/`.
**Request Body:** **Request Body:**
@@ -1501,9 +1650,9 @@ Generate a self-signed certificate for a domain. Idempotent — skips if `fullch
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `domain` | `string` | Domain name | | `domain` | `string` | Domain name |
| `cert` | `string` | Path to `fullchain.cer` | | `cert` | `string` | Path to `data/certs/<domain>.crt` |
| `key` | `string` | Path to `<domain>.key` | | `key` | `string` | Path to `data/certs/<domain>.key` |
| `generated` | `boolean` | `true` if a new cert was created, `false` if existing cert was reused | | `generated` | `boolean` | `true` if a new cert was created, `false` if the existing cert was reused |
## WireGuard API ## WireGuard API
@@ -1593,14 +1742,9 @@ Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
POST /api/wireguard/down 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`):** **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.
| Field | Type | Description |
|-------|------|-------------|
| `down` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems auto-synced as a result |
### Status ### 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 | | `up` | `boolean` | Whether the tunnel interface is up |
| `interface` | `object` | Interface info (listen port, public key) | | `interface` | `object` | Interface info (listen port, public key) |
| `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) | | `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 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:** **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 | | `name` | `string` | Yes | Peer identifier name |
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) | | `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) | | `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) |
| `preshared_key` | `string` | No | Preshared key | | `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 GET /api/wireguard/classes
``` ```
Return all configured access classes. Return all configured access classes. Private keys are stripped.
**Response (`data`):** **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 #### Create Access Class
@@ -1759,19 +1906,16 @@ Create a new access class.
| Field | Type | Required | Description | | 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) | | `name` | `string` | No | Display name (defaults to key) |
| `description` | `string` | No | Description text | | `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 | Returns HTTP `400` if the `key` is missing or is not lowercase alphanumeric. Returns HTTP `409` if the key already exists.
|-------|------|-------------|
| `key` | `string` | Class key |
| `name` | `string` | Display name |
| `description` | `string` | Description |
Returns HTTP `409` if the key already exists.
#### Update Access Class #### Update Access Class
@@ -1779,7 +1923,7 @@ Returns HTTP `409` if the key already exists.
PATCH /api/wireguard/classes 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:** **Request Body:**
@@ -1788,8 +1932,11 @@ Update an existing access class.
| `key` | `string` | Yes | Class identifier | | `key` | `string` | Yes | Class identifier |
| `name` | `string` | No | New display name | | `name` | `string` | No | New display name |
| `description` | `string` | No | New description | | `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. 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/<key>/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/<key>/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/<key>/down`).
**Response:** `data` is `null` on success.
Returns HTTP `404` if the class does not exist.
---
#### Get Class Status
```
GET /api/wireguard/classes/<key>/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/<key>
```
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 ## Network API
Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters. 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 | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `name` | `string` | Interface name | | `name` | `string` | Interface name |
| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable | | `applied` | `boolean` | Always `true` |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
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. 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/<name>/reload POST /api/network/interfaces/<name>/reload
``` ```
Reload networkd for a single interface (runs `networkctl reload <name>`). Reload networkd for a single interface (runs `networkctl reconfigure <name>`, not `networkctl reload`).
**Response (`data`):** **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"`) | | `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. 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:** **Request Body:**
| Field | Type | Required | Description | | 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 | | `value` | `string` | Yes | Value to set |
**Response (`data`):** **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 | | `name` | `string` | Parameter name |
| `value` | `string` | Value set | | `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.
--- ---
+161 -80
View File
@@ -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). 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. 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`. 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. 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 backend service processes the request and returns an HTTP response. 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. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response. 7. The backend service processes the request and returns an HTTP response.
8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface. 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. 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. 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. 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 <token>` 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 <token>` 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. 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. 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. 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: 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 ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db) 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/auth.py ──→ lib/auth.py ──→ JWT operations
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables 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/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 ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq 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) ──→ deploy hook (daemon API) ──→ ACME provider 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 data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render per-class config (wg-<class>) ──→ /run/vacuum-wall/<ifname>.conf.tmp (0600) ──→ sudo cp to /etc/wireguard/<ifname>.conf ──→ sudo wg-quick up <ifname>
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload vacuum-walld ──→ daemon/handlers/network.py ──→ render 99-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload + sudo networkctl reconfigure <iface>
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal 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-<class>`, rendered to `/etc/wireguard/wg-<class>.conf`. Legacy single-interface mode renders `/etc/wireguard/wg0.conf`.
### Two-User Model with Shared Group ### Two-User Model with Shared Group
Vacuum Wall uses two distinct system users bridged by a 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`. - **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:<group>` 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:<group>` 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 <iface>` (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. **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 | | 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 | | 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`). 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 ## 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"). 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 <token>` and `X-Session-Id` headers — a request with only one of the two is rejected with 401.
## Database Layer ## 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_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path | | `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 ## 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. | | 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. | | 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/<domain>.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. | | nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.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. | | WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/<ifname>.conf` — per-class `wg-<class>.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-<class>.conf` (class interface `wg-<class>`) 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/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. | | networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/99-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `99-<name>.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. | | 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 #### Background Polling
@@ -149,49 +162,90 @@ The daemon runs background polling tasks for subsystems with external runtime st
| dnsmasq | 10s | Lease file + service status | | dnsmasq | 10s | Lease file + service status |
| networkd | 10s | Interface up/down, DHCP address changes | | networkd | 10s | Interface up/down, DHCP address changes |
| system | 1s | Real-time metrics (load/memory/swap/traffic) | | system | 1s | Real-time metrics (load/memory/swap/traffic) |
| nginx | 60s | 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 | Config-file drift self-heal (lazy in-place migration) | | 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: **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` - **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 - **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump
- **No change**: silence - **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,...`). 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 ## System Config Import
On daemon startup, `lib/system_import.py` reconciles live system configurations On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
with the declarative JSON configs. This ensures that configurations created to reconcile live system configuration with the declarative JSON configs. This
by `scripts/install.sh` or edited manually in system files are imported into ensures that configurations created by `scripts/install.sh` or edited manually
the JSON source of truth, preventing drift. 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 Each subsystem import function parses the corresponding live system config and
import function: updates the JSON config when they differ:
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed - **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
block between comment markers) → `config/dnsmasq/config.json`. Only writes block between comment markers) → `config/dnsmasq/config.json`. Only writes
if config doesn't exist or differs. if config doesn't exist or differs.
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf` - **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf`
`config/wireguard/config.json`. Skips if configs match. `config/wireguard/config.json`. Skips if configs match.
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files - **`import_networkd`**: Globs **all** `/etc/systemd/network/*.network` files
(install-time files) → `config/network/config.json`. Only adds/updates `config/network/config.json`, stripping any numeric priority prefix from the
interfaces; doesn't remove interfaces without a file (they may be pending apply). filename (`99-eth0.network``eth0`; `eth0.network``eth0`). Only
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf` adds/updates interfaces; doesn't remove interfaces without a file (they may
`config/nginx/config.json`. Only touches vacuum-wall-managed files be pending apply).
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`. - **`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` - **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones`
`config/firewall/config.json`. Only writes if no config file exists `config/firewall/config.json`. Only writes if no config file exists
(firewalld state always takes precedence). (firewalld state always takes precedence).
Import failures are silently logged as warnings — they never abort daemon startup. All imports are **idempotent** and **non-destructive**: they only write when
The returned list of updated subsystems is logged for debugging. 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 ## 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 - **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
for each interface serving a DHCP range. Back-propagates gateway (interface for each interface serving a DHCP range. Back-propagates gateway (interface
IP) into DHCP ranges so clients receive their default route. IP) into DHCP ranges so clients receive their default route.
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with - **WgToFirewallSync**: Manages **per-access-class** firewall zones: for each
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone access class with peers, ensures a `vpn-<key>` zone exists with the class's
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created WireGuard interface (`wg-<key>`), masquerade enabled, and a UDP accept
entries when no active peers exist. rich rule on the class's `listen_port` (default 51820). Classes with
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer `lan_access: true` additionally get inter-zone accept rules for internal
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the subnets (derived from zones without masquerade); `lan_access: false`
gateway (interface IP). Logs warnings for zones with dhcp service but no range. (internet-only) classes get no internal rules. Stale class zones
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without (`vpn-<key>` whose class no longer has peers) have their WireGuard-created
ranges. Syncs firewall zone interface assignments — adding new interfaces entries cleaned up. The single `vpn`/51820 zone is managed **only as a
and removing stale ones no longer in network config. 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 4. The handler refreshes state for the originating subsystem plus all
transitively affected subsystems. 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 indicator on the firewall page will show pending when DHCP or WireGuard saves
(since sync writes JSON but does not call firewall-cmd). (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` The daemon's `main()` (`daemon/server.py`) runs a fixed startup sequence after
to reconcile any drift between system configuration files and the declarative the aiohttp app is listening on the Unix socket and WebSocket port:
JSON configs. This is invoked from `daemon/server.py` during initialization.
Each subsystem import function parses the corresponding live system config and 1. **`system_import.import_all()`** — reconciles live system configs into the
updates the JSON config if they differ: 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
| Subsystem | Source | Condition | start.
|---|---|---| 2. **`bootstrap()`** (`lib/bootstrap.py`) — creates the runtime `config/` +
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers | `data/` directories for all subsystems and persists the one-shot nginx
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet | legacy-format migration (`nginx.migrate_config_file()`). Idempotent. It
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format | deliberately never creates config *files*: `get_config` reads are pure
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files | (missing file → in-memory defaults), so files are materialized on the first
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks | `save_config` (or by the import itself).
3. **`normalize_acme_home()`** — reopens group access on the acme.sh tree
All imports are **idempotent** and **non-destructive**: they only write when (acme.sh hardens it to owner-only on every run); a failure here is logged,
configs differ, skip on failure (logged as warnings), and never abort daemon never fatal.
startup. This ensures that manual edits to system files (e.g., during install 4. **First `state_store.populate()`** — collects all subsystem state; the
or troubleshooting) are reconciled into the declarative JSON source of truth. 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 ## Directory Structure
@@ -288,7 +355,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
``` ```
data/ 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/ ├── nginx/
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only) │ ├── .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) │ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
@@ -300,14 +367,14 @@ data/
├── logs/ ├── logs/
│ └── vacuum-wall.log # Application log file │ └── vacuum-wall.log # Application log file
└── wireguard/ # WireGuard runtime artifacts └── wireguard/ # WireGuard runtime artifacts
├── networkd/ # Generated 50-<name>.network files ├── networkd/ # Generated 99-<name>.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. 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. 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 ## 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/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/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/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/wireguard/<ifname>.conf` | Generated WireGuard interface configuration, written from `config/wireguard/config.json`. Per-class `wg-<class>.conf` in multi-interface mode; legacy single interface `wg0.conf`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) | | `/etc/systemd/network/99-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | | `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) | | `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq, 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` | 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/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) | | `/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. 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 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 Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API apiFetch() ──→ injects BOTH Authorization: Bearer <token> and X-Session-Id headers ──→ Flask REST API
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions 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 <token>` subprotocol + X-Auth-Token header fallbacks accepted) Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer <token>` subprotocol + X-Auth-Token header fallbacks accepted)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM 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 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 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/<path>` 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. The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries. 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. 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 ### 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. 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 ## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level: The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
+138 -84
View File
@@ -48,7 +48,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| Field | Type | Required | Description | | 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` | 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[].start` | string | Yes | First IP address in the pool. |
| `ranges[].end` | string | Yes | Last 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`. | | `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. | | `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` | 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[].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. | | `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
### DNS Fields ### DNS Fields
@@ -76,42 +76,15 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
**File**: `config/nginx/config.json` **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 ```json
{ {
"domains": { "backends": {
"app.example.com": { "webui": {
"force_ssl": true, "label": "Vacuum Wall WebUI",
"cert": "acme", "builtin": true,
"auth": { "_migrated": true,
"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",
"paths": { "paths": {
"/": { "/": {
"backend": { "backend": {
@@ -120,10 +93,7 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"proto": "http" "proto": "http"
}, },
"is_management": true, "is_management": true,
"auth": { "auth": null
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
}, },
"/ws": { "/ws": {
"backend": { "backend": {
@@ -134,6 +104,37 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"is_websocket": true "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": { "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 | | 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. | | `label` | string | Yes (on create) | Human-readable display name for the backend. Required when adding via `POST /nginx/backends/add`. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. | | `paths` | object | Yes | Path-to-config map (schema in [Path Entries](#path-entries) below). |
| `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 | Backend-level HTTP basic auth (`{ user, htpasswd }`). Used by any domain referencing this backend unless overridden at the domain level. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path 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 ### 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 | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `backend` | object | Yes | The upstream service for this path. | | `backend` | object | Yes | The upstream service for this path. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. | | `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. | | `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. | | `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. | | `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-level auth. `null` disables auth for this path. | | `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. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. | | `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. | | `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 ### 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: null` means "no auth" for that path.
- Path-level `auth: { ... }` overrides domain-level for that path. - Path-level `auth: { ... }` overrides for that path.
- No other domain-level settings inherit — `headers` is path-only. - 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 ### Path ordering
@@ -188,12 +209,14 @@ The `cert` field is a string that selects the provisioning method:
| Value | Description | | 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/`. | | `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/`. | | `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 ### 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/<domain>.crt` / `data/certs/<domain>.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: 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 ### Backward Compatibility
Config files using the legacy format are auto-migrated on first load: 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:
- 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. 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 ### 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 | | 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: `""`. | | `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 ### Account Registration
ACME account registration is handled entirely through the WebUI. When the user registers an account: 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". 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). 2. Provides an email address and a CA server (defaults to `letsencrypt`).
3. The backend calls `acme.sh --register-account` with the provided parameters. 3. The backend calls `acme.sh --register-account -m <email> --server <ca>`.
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/`. 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. 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: 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 <email>` (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. - **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 Home Directory
acme.sh stores its state under `data/acme/` (the ACME home directory). Key files: 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`). - `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.
- `<domain>/` — Per-domain certificate and key files issued by acme.sh. - `ca/<server>/` — Per-CA account files, keyed by the ACME server name (e.g., `ca/letsencrypt/`).
- `<domain>/` — Per-domain certificate and key files issued by acme.sh. For ECC certificates the directory is `<domain>_ecc/`; `find_cert_dir()` checks the `_ecc` directory first, then the plain `<domain>/` 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 ## Auth Configuration
@@ -278,9 +307,8 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
"algorithm": "HS256" "algorithm": "HS256"
}, },
"webauthn": { "webauthn": {
"rp_name": "Vacuum Wall", "enabled": true,
"rp_id": "<management-domain>", "rp_name": "Vacuum Wall"
"origin": "https://<management-domain>"
} }
} }
``` ```
@@ -289,7 +317,7 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
| Field | Type | Required | Description | | 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). | | `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. | | `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 ### WebAuthn Fields
The WebAuthn config block holds only two fields:
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. | | `enabled` | boolean | No | Whether WebAuthn is enabled. Default: `true`. |
| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. | | `rp_name` | string | No | Display name for the WebAuthn Relying Party. Shown during credential registration. Default: `"Vacuum Wall"`. |
| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://<rp_id>`. |
`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 ## 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 ### users
@@ -336,7 +367,17 @@ UNIQUE constraint on `(username, subsystem)`.
| `token_type` | TEXT | `"access"` or `"refresh"` | | `token_type` | TEXT | `"access"` or `"refresh"` |
| `expires` | INTEGER | Unix timestamp of token expiry | | `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 ### 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)`. UNIQUE constraint on `(username, credential_id)`.
### init_sequence
| Column | Type | Description |
|---|---|---|
| `seq` | INTEGER | Primary key — bookkeeping sequence marker |
## WireGuard Configuration ## WireGuard Configuration
**File**: `config/wireguard/config.json` **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
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), 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/<key>/up`, `POST /api/wireguard/classes/<key>/down`. Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), 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/<key>/up`, `POST /api/wireguard/classes/<key>/down` (the down route forwards to the daemon's `DELETE /wireguard/classes/<class_key>/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 | | 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: `""`. | | `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 ``<base>.1/<prefix>``. | | `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. | | `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. | | `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`. | | `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`. | | `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`. | | `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 ### 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 ### 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[].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. | | `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` | 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`. | | `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 ### 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/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift. Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
@@ -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. **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) ## Networkd (IP Configuration)
**File**: `config/network/config.json` **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-<name>.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-<name>.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`.
```json ```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. | | `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. | | `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. | | `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. | | `bind_carrier` | `array` | Carrier interfaces to bind to. |
| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. | | `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. |
| `keep_configuration` | `boolean` | Keep configuration on stop. | | `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 ### Generated Files
Each interface config entry produces a `50-<name>.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-<name>.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 ## 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. | | 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-<key>` zones are created with `wg-<key>` 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. | | wireguard (peer add/remove) | firewall | Per-class `vpn-<key>` zones are created with `wg-<key>` 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) | 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. | | network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
+40 -24
View File
@@ -40,7 +40,7 @@ All settings that can be passed as an environment variable also have a CLI flag
| Flag | Env Var | Required | Description | | 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-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-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`. | | `--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: 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. - **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. - **`.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:<group>` (mode `0660`). The repo owner accesses it via primary group membership. - **Daemon socket** (`data/daemon.sock`): Owned by `walld:<group>` (mode `0660`). The repo owner accesses it via primary group membership.
### Running the Installer in Dev Mode ### 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 ./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 ### 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 --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. - **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. - **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. - **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). - **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). - **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. - **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.
- **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`. - **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. - **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. - **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. - **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 (`<hostname>.local`) on the local network. - **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<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). - **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/<domain>.crt` and `data/certs/<domain>.key` — not under `data/acme/`, where acme.sh stores issued certs. Idempotent: skips generation when both files already exist.
- **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`. - **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 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. - **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**: Firewall config and nginx proxy config are written via daemon API (skips if already exists). - **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. - **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-walld.service` — the privileged background daemon (aiohttp, daemon socket).
- `vacuum-wall.service` — the Flask WebUI backend. - `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot. - `vacuum-wall-acme.service` — the certificate renewal oneshot.
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals. - `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals (no template variables).
- **Firewalld zones**: Creates initial zones:
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed. 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`).
- `vpn` — WireGuard tunnel zone. - **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. - **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. - **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) - Skips the Python venv (use `--force-venv` to rebuild)
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes - 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 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 - 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. 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_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path | | `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 ### Post-Deploy Verification
@@ -297,7 +310,9 @@ journalctl -u vacuum-wall --no-pager -n 50
nginx -t 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/<domain>_*.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 ### 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` 1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld`
2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"` 2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"`
3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db` 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. Start services: `sudo systemctl start vacuum-walld vacuum-wall` 4. Re-set the password via the WebUI, or use the SQLite steps under "Locked Out of WebUI".
### WebUI Not Accessible ### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`. 1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`. 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`. 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`. 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. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate. 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.
--- ---
+399 -104
View File
@@ -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 | | Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching | | 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 | | Render | `render.js` | Render engine: container-level diffing, component lifecycle |
| Component | `component.js` | Page definitions, lifecycle hooks, state caching | | Component | `component.js` | Page definitions, lifecycle hooks, state caching |
| Router | `router.js` | Hash-based SPA router, `Link` navigation component | | 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 | | 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) | | 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 | | API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing | | Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts | | 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 | | 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 ## 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 WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(snapshot on connect, versions/tick deltas per subsystem) (snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch() 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`). 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`: The app starts from `webui/static/app.js`:
```javascript ```javascript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch,
modelRegister, modelFetch, reactive } from '/static/hoover/index.js'; 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 // 1a. Auth model — registered first. Silent topic: the daemon never
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the // broadcasts 'auth', so refreshByTopic() can never fetch it.
// primary data path is the WS snapshot + deltas (modelSet). 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 = [ const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' }, { name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' }, { 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('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } }); modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } });
```javascript
// ... more modelRegister calls ...
// 2. Initial data. State-backed models receive their first data via the WS // 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. // snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP)
// Non-state models fetch immediately. // if it hasn't arrived. Non-state models fetch immediately.
function fetchInitialData() { function fetchInitialData() {
for (const { name } of STATE_MODELS) { for (const { name } of STATE_MODELS) {
setTimeout(() => { setTimeout(() => {
@@ -108,29 +118,59 @@ function fetchInitialData() {
modelFetch('logs', 'journal'); modelFetch('logs', 'journal');
} }
// 3. Create reactive router state // 3. Custom router — reactive path state plus the auth guard (see Router below)
const router = { const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }), state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() { 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; const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path); return hComp(page, path);
}, },
}; };
// 4. Listen for hash changes // 4. Init: session check before mounting, listeners, conditional boot
window.addEventListener('hashchange', () => { export async function initApp() {
router.state.path = location.hash.slice(1) || '/dashboard'; // 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 // Check the session BEFORE mounting the shell: an unauthenticated
render(sidebarEl, Sidebar); // visitor must never flash the sidebar or a protected page.
render(mainEl, MainContent); 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) // Mount render roots (Sidebar renders null when unauthenticated)
setTimeout(connect, 0); 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 ## Reactivity
### `reactive(obj)` ### `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. 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 ```javascript
// Correct — assigns a new array // 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 ```javascript
// HTTP fallback for a state-backed model (WS snapshot is the primary path; // HTTP fallback for a state-backed model (WS snapshot is the primary path;
@@ -256,7 +296,7 @@ modelFetch('logs', 'nginx-access');
**Behavior:** **Behavior:**
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup). - 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. - Clears `model.error` before fetch.
- On success, assigns result to `model.data`. - On success, assigns result to `model.data`.
- On failure, stores error in `model.error`. - 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** / `null` payload (a failed collector keeps the current data). See **WS Message Types** /
**WS Data Streaming Flow** below. **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 Refresh all models whose subsystem topic matches via `modelFetch()`.
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via **Not re-exported from `hoover/index.js` and never called anywhere** —
`modelSet` instead). `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? | | Model `subsystem` | Topic | Match? |
|---|---|---| |---|---|---|
@@ -319,8 +360,10 @@ 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 `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 to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL 60s timer), internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL 60s
session validation, login/logout transitions, and WS reconnection coordination. 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()` Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on (requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
@@ -338,15 +381,19 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
``` ```
app bootstrap → modelFetch('auth', { action: 'check' }) app bootstrap → modelFetch('auth', { action: 'check' })
→ 200: stores verified user/permissions + stored tokens → schedules refresh → 200: stores verified user/permissions + stored tokens → schedules the
→ 401 with a stored refresh token (stale access token after page refresh at the token's REMAINING lifetime (exp claim, not the full issued
reload/restore): exactly one refresh attempt, then the same TTL) minus 60s (minimum 30s)
success or terminal path → 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) (no auth:login — initApp() calls fetchInitialData()/connect() directly)
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects → onSuccess stores rotated tokens (new session_id) or clears + redirects
(no auth:login dispatch) (no auth:login dispatch)
timer fires (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) WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
login → modelFetch('auth', { action: 'login', payload: data }) login → modelFetch('auth', { action: 'login', payload: data })
→ onSuccess stores + schedules + fires auth:login (login action only) → onSuccess stores + schedules + fires auth:login (login action only)
@@ -361,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 - **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, (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 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 by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback
(exactly one refresh when the stored access token is rejected at page load while a (exactly one refresh when the session check gets a non-OK response at page load while a
refresh token is still present). refresh token is still present).
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`. - **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model - **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
@@ -376,12 +423,23 @@ any terminal no-token result → onSuccess dispatches auth:logout
(app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js` (app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js`
(would cycle) — the event inverts the dependency. (would cycle) — the event inverts the dependency.
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any - **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** post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must
`Authorization` and `X-Session-Id` from `getAuthData()`. 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: - **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 `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 (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
secondary guard for the timer path. secondary guard for the timer path.
- **Exp-claim TTL**`data.ttl` is the access token's *remaining* lifetime, decoded
unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own
unverified-payload extraction in `lib/auth.py`); the full issued TTL
(`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is
undecodable or the token is already expired. This keeps the in-memory refresh timer
correct on page restore: a session resumed mid-life schedules its refresh from the
actual expiry, not from the moment the model was (re)populated. An already-expired
stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh
path or the first `apiFetch` 401.
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so - **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
without the terminal `auth:logout``disconnect()` path the previous user's socket would without the terminal `auth:logout``disconnect()` path the previous user's socket would
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket). survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
@@ -399,11 +457,20 @@ h('div', { class: 'card' }, h('span', null, 'Hello'))
// Text node // Text node
h('#text', 'some text') 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' }, []) 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) ### HTM (Tagged HTML Templates)
@@ -469,15 +536,16 @@ html`<${Badge} ...${badgeProps} />`
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute | | `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute | | `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
| `disabled` | Sets `.disabled` boolean property on applicable elements | | `disabled` | Sets `.disabled` boolean property on applicable elements |
| `selected` | On `<option>`: sets `.selected` |
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) | | `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
| `key` | Used by keyed diff algorithm; not applied to DOM | | `key` | Used by keyed diff algorithm; not applied to DOM |
| `ref` | Reserved (no-op); not applied to DOM | | `ref` | Reserved (no-op); not applied to DOM |
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute. All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute; a `true` value sets the attribute to the empty string.
### Diffing ### Diffing
The diff algorithm uses index-based unkeyed diffing by default. When any VNode in a sibling set has a `key` prop, the keyed algorithm is used for the entire set. Keyed diff preserves DOM element order and reuses elements by key. The diff algorithm uses index-based unkeyed diffing by default. The keyed algorithm is used for a sibling set only when **both** the old and the new children arrays contain at least one keyed VNode; otherwise (e.g. keys appearing for the first time, or keys disappearing) the set is diffed unkeyed. When keyed, diff preserves DOM element order and reuses elements by key.
Use `key` when rendering lists that can be reordered, inserted, or removed: Use `key` when rendering lists that can be reordered, inserted, or removed:
@@ -500,7 +568,7 @@ function View() {
render(document.getElementById('root'), View); render(document.getElementById('root'), View);
``` ```
The render function executes on every reactive update. It can return a single VNode or an array of VNodes. The render function executes on every reactive update. It can return a single VNode, an array of VNodes, or a **function** returning VNodes (a lazy VNode provider — the engine invokes it before normalizing).
## Pages ## Pages
@@ -510,6 +578,9 @@ Define a page component with reactive state and rendering. Pages access data thr
```javascript ```javascript
export default definePage({ export default definePage({
// Browser tab title — applied to document.title on mount
title: 'Zones - Vacuum Wall',
// Return initial state — models are obtained via getModel() // Return initial state — models are obtained via getModel()
init() { init() {
return { return {
@@ -517,9 +588,10 @@ export default definePage({
}; };
}, },
// Optional: one-time setup on mount (e.g., opening a modal dialog) // Optional: one-time setup on mount. Receives (state, abortController) —
// Not used for data loading — model layer handles that // use the controller's signal for any page-local fetches. Not used for
async load(state) { // data loading on model-backed pages — the model layer handles that.
async load(state, abortController) {
// Rarely needed // Rarely needed
}, },
@@ -528,34 +600,39 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones); const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
if (guard) return guard; if (guard) return guard;
const zones = state.firewall.data?.zones?.available || []; // firewall.data.zones is an object keyed by zone NAME:
// { 'zone1': { interfaces: [...], services: [...], target: ..., masquerade: ... }, … }
const zoneNames = Object.keys(state.firewall.data?.zones || {});
return [ return [
PageHeader({ title: 'Zones' }), PageHeader({ title: 'Zones' }),
zones.map(z => h('div', { class: 'card', key: z }, esc(z))), zoneNames.map(z => h('div', { class: 'card', key: z }, esc(z))),
]; ];
}, },
// Optional: cleanup on unmount // Optional: cleanup on unmount
onUnmount(state) { onUnmount(state) {
// abort pending fetches, clear cached state // clear cached state
}, },
}); });
``` ```
Pages get data from models reactive — they never call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives. Pages get data from models reactive — model-backed pages do not call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives. (Exception: `users.js` and `passkeys.js` fetch page-local data with `apiFetch` in `load()` against a module-level reactive state — see **Module-level shared reactive state**.)
**`load` abort semantics:** `load(state, abortController)` runs once per mount via a microtask after the component enters the tree. The controller is aborted (and `load` re-run) when a **remount** of the same key happens — the render engine re-mounts an existing component by aborting its previous in-flight load first — and on **unmount**, so a detached page's load cannot mutate state after it leaves the tree. Check `abortController.signal.aborted` (or pass the signal to `apiFetch`) before writing results.
### Page Definition Properties ### Page Definition Properties
| Property | Required | Description | | Property | Required | Description |
|---|---|---| |---|---|---|
| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. |
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. | | `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
| `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. | | `load(state, abortController)` | No | Optional one-time setup called on mount (microtask-deferred). Receives a fresh `AbortController`, aborted on remount/unmount. Not used for data loading on model-backed pages — use the model layer instead. |
| `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. | | `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). | | `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
### Page Lifecycle ### Page Lifecycle
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key. 1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `load()` fires if defined → component tracked by key.
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM. 2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`. 3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed. 4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
@@ -564,29 +641,91 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads). Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
The `#comp` lifecycle registry (and the expanded-content cache) is **per render container**: a
commit of one root (e.g. `#sidebar`) never unmounts or prunes components owned by another root
(e.g. `#main`'s page). Since `commitAll()` commits every root on each reactive update, a shared
global registry would make the sidebar's commit remount the page on every WS tick/toast/model
update — re-running `load()` and, for pages whose `load()` re-mutates reactive state, spinning
an infinite unmount/remount/load loop.
```javascript ```javascript
// Router pattern — key is the path so navigation to a different page unmounts the old one // Router pattern — key is the path so navigation to a different page unmounts the old one
return hComp(page, this.state.path); return hComp(page, this.state.path);
``` ```
### Module-level shared reactive state
For data that does not belong to the daemon state store (or doesn't warrant a
registered model), pages can keep a **module-level reactive state object** and
fetch it with `apiFetch` in `load()`. `init()` returns the same object, so
state survives across mounts of the page (it lives in the module, not the
component), and the page's `load(s, abortController)` fetches into it:
```javascript
// pages/users.js / pages/passkeys.js — page-local data, no registered model
const state = reactive({ users: [], loading: true, refreshing: false, error: null });
async function loadUsers(abortController) {
if (abortController?.signal?.aborted) return;
if (state.users.length) state.refreshing = true; // existing data → refresh
else state.loading = true;
state.error = null;
const r = await apiFetch('/api/auth/users', { signal: abortController.signal });
if (abortController?.signal?.aborted) return;
if (r.ok) state.users = r.data || [];
else state.error = r.error;
state.loading = false;
state.refreshing = false;
}
export default definePage({
title: 'Users - Vacuum Wall',
init() { return state; },
async load(s, abortController) {
await loadUsers(abortController);
},
render(s) { /* guard on s.loading / s.error, render s.users */ },
});
```
This is the pattern `users.js` and `passkeys.js` use. Because the state
outlives a single mount, manage `loading`/`refreshing` by data presence (as
above) and always check `abortController.signal.aborted` before writing
results.
## Router ## Router
### Custom Router Pattern (Used by Vacuum Wall) ### Custom Router Pattern (Used by Vacuum Wall)
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with `hashchange` listener handles navigation: The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with a `hashchange` listener handles navigation. Two auth mechanisms are built in:
1. **Auth guard in `component()`** — any non-`/login` path while unauthenticated renders the `LoginPage` (reactive: the auth model's data mutation re-renders this, so the real page appears the instant login completes; covers manual hash entry, back/forward, and runtime expiry).
2. **Hash clamping in `hashchange`** — once the bootstrap session check has settled (`authChecked`), a hash change to a protected route while unauthenticated is clamped to `/login` and the URL is kept in sync (loop-safe: the follow-up `hashchange` lands on the already-clamped path). Until the check settles, the clamp stays off so a valid-session reload still in flight is not stranded on login.
```javascript ```javascript
const router = { const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }), state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() { 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; const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path); return hComp(page, path);
}, },
}; };
// Set once the bootstrap session check settles (and implicitly on every
// later login/logout transition — isAuthenticated flips reactively).
let authChecked = false;
window.location.hash || (window.location.hash = router.state.path);
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard'; const raw = location.hash.slice(1) || '/dashboard';
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
router.state.path = path;
if (location.hash.slice(1) !== path) location.hash = path; // clamp the URL too
}); });
``` ```
@@ -604,13 +743,23 @@ const router = createRouter({
Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function. Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function.
Built-in behavior:
- **Initial-hash seeding** — if `location.hash` is empty on creation, it is seeded from the initial path (default `'/dashboard'`), so the URL and router state start in sync.
- **Built-in `hashchange` listener** — registered by `createRouter()` itself; `state.path` updates (and re-renders) automatically on navigation.
- **Unknown routes** — a route with no handler and no `'*'` fallback renders a 404 card (`404 — Not found: <path>`) instead of throwing.
- **Error fallback** — a route handler that throws renders an error card with the exception message instead of crashing the render root.
### `Link(props)` ### `Link(props)`
Client-side navigation link. Sets `location.hash` without full page navigation. Accepts `path`, `class`, `children`. Client-side navigation link. Sets `location.hash` without full page navigation (the click is intercepted with `preventDefault`). Accepts `path`, `class`, `children`, and spreads any **extra props** onto the anchor element.
```javascript ```javascript
Link({ path: '/zones', class: 'active', children: ['Zones'] }) Link({ path: '/zones', class: 'active', children: ['Zones'] })
// Renders: <a href="#/zones" class="active">Zones</a> // Renders: <a href="#/zones" class="active">Zones</a>
Link({ path: '/zones', id: 'nav-zones', title: 'Zone management', children: ['Zones'] })
// `id` and `title` are spread onto the <a>
``` ```
## WebSocket ## WebSocket
@@ -619,7 +768,14 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s). Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise. The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). The handshake sends **only the token**`X-Session-Id` is an HTTP-only header and is not part of the socket handshake. With no token, no socket is created (the daemon 401s unauthenticated WS connections).
Reconnection policy:
- After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
- **Give-up cap:** the refresh→reconnect cycle is an "episode" (3 closed connections each). After **2 consecutive failed episodes** the WS path is abandoned (`_wsGivingUp`) until the page is reloaded — the UI keeps working via the REST API, and a fresh page load (or the next successful socket open) restarts the cycle. This prevents a dead WS path from looping `refreshAuth()` forever (each successful refresh rotates the token pair).
- A successful socket open resets all counters (backoff, fail count, refresh streak, giving-up flag).
- **No "reconnect recovery" HTTP fallback** — after the socket re-establishes, the daemon re-sends the full **snapshot**, which `modelSet` applies. The only HTTP path for state-backed models is the one-shot 3s initial-load timer in `app.js` (and explicit fallback fetches).
### `disconnect()` ### `disconnect()`
@@ -670,14 +826,16 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
- Automatically sets `Accept: application/json`. - Automatically sets `Accept: application/json`.
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`. - If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them. - When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`. - **Public-auth-URL exception:** 401 recovery is skipped for `/api/auth/login` and the WebAuthn authenticate endpoints (`/api/auth/webauthn/authenticate-begin`, `/api/auth/webauthn/authenticate-finish`) — a failed login (bad credentials) can legitimately 401 while a valid session exists elsewhere and must not tear it down.
- On non-2xx, returns `{ ok: false, error: "message", status }`. - On HTTP 401 (with a token present, non-public-auth URL), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`. - If `options.signal` was aborted by the time the response returns, returns `{ ok: false, data: null, error: 'Aborted', status: 0 }`.
- On non-2xx, returns `{ ok: false, data: null, error: json.error || 'HTTP <status>', status }`.
- On network error, returns `{ ok: false, data: null, error: e.message || 'Network error', status: 0 }`.
- Passes `credentials: 'same-origin'` by default. - Passes `credentials: 'same-origin'` by default.
### `toast(message, type, duration)` ### `toast(message, type, duration)`
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID. Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'` (default: `'info'`). Returns a toast ID.
When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default. When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default.
@@ -716,7 +874,7 @@ apiSubmit({
}), }),
``` ```
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`. Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`. The descriptor carries `processing: true`, so the button renders a spinner and stays disabled while the submit is in flight (see the `formModal` action `processing` flag below). The handler also checks the modal-processing guard (`isModalProcessing()` / `setModalProcessing()`) and calls `refreshModals()` in `finally`.
**Parameters:** **Parameters:**
@@ -725,8 +883,9 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
| `url` | API URL | | `url` | API URL |
| `method` | HTTP method (default: `'POST'`) | | `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` function, or `undefined` for no body | | `body` | `() => body` function, or `undefined` for no body |
| `validate` | `(body) => string | null` — validation function | | `validate` | `(body) => string \| null` — validation function; errors are toasted |
| `successMsg` | Success toast message | | `confirm` | `(body) => string \| null` — if a message is returned, a native `confirm()` dialog gates the submit; on approval the body gains `force: true` (server-side guard override) |
| `successMsg` | Success toast message (default: `'Saved'`) |
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) | | `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
| `submitText` | Submit button text (default: `'Submit'`) | | `submitText` | Submit button text (default: `'Submit'`) |
@@ -734,6 +893,31 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
> updated by the WS delta after the mutation. To refresh a non-state model after > updated by the WS delta after the mutation. To refresh a non-state model after
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component. > success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
### `formAction(fn)`
Wrap a custom async modal handler with the standard processing-guard machinery. Use it for any modal action that does **not** use `apiSubmit`.
- Refuses to run while the modal is already processing (`isModalProcessing()`).
- Sets the processing flag, runs `fn()`, clears the flag, and re-renders the modal (`refreshModals()`) in `finally`.
- Errors thrown by `fn()` (e.g. failed validation) are toasted as `toast(e.message || 'Failed', 'error')`.
The wrapped handler receives no arguments — it performs validation (via `throw`), API calls, success/error toasting, and modal closing itself.
```javascript
openModal((inner) => {
formModal(inner, 'Rotate', fields, [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{ label: 'Rotate', cls: 'btn-primary', action: 's', handler: formAction(async () => {
const name = $val('rotate-name');
if (!name) throw new Error('Name required');
const r = await apiFetch('/api/rotate', { method: 'POST', body: { name } });
if (r.ok) { toast('Rotated', 'success'); closeModal(); }
else toast(r.error || 'Failed', 'error');
}) },
]);
});
```
### `checkAbort(ac)` ### `checkAbort(ac)`
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management. **Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
@@ -751,7 +935,7 @@ const r2 = await apiFetch('/api/second', { signal });
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management. **Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
Async load wrapper that encapsulates `loading`/`refreshing` flag management, abort checking, and staleness guards. Used for page-local fetches that don't go through the model layer. Async load wrapper that encapsulates `loading`/`refreshing` flag management (when `opts.entry` is provided) and abort checking. Used for page-local fetches that don't go through the model layer. Note: despite accepting `entry.requestId`, **no staleness check is performed**.
```javascript ```javascript
import { refactorLoad } from '/static/hoover/index.js'; import { refactorLoad } from '/static/hoover/index.js';
@@ -778,8 +962,8 @@ async function load(state, abortController, entry) {
|---|---| |---|---|
| `state` | Page state object | | `state` | Page state object |
| `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) | | `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort/stale status between sequential fetches | | `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort status between sequential fetches |
| `opts.entry` | Router entry with `requestId` for staleness checks | | `opts.entry` | Component entry. Its `requestId` is read but **never used** — there is no staleness check. The `loading`/`refreshing` flags are set and cleared **only when `entry` is provided**; without it the wrapper only clears/sets `error` |
| `opts.abortController` | AbortController for cancellation | | `opts.abortController` | AbortController for cancellation |
### `poll(opts)` ### `poll(opts)`
@@ -815,7 +999,7 @@ poll({
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` | | `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` | | `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
| `onComplete` | `(data) => void`, called on success | | `onComplete` | `(data) => void`, called on success |
| `onError` | `(data) => void`, called on error or timeout | | `onError` | Called on error or timeout. On an HTTP failure it receives the **whole `apiFetch` result** (`{ ok: false, error, status }`); on timeout it receives `null`; on an `onErrorKey` match it receives the response `data` |
## UI Components ## UI Components
@@ -920,7 +1104,7 @@ if (guard) return guard;
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available. `renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
Checks `state.loading`, `state.error`, and data presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…". Branch order: (1) **loading** — entered only when `state.loading && !state.refreshing` (i.e. the initial load, before any data has arrived), showing a "Loading…" card. (The code contains a `Refreshing…` variant inside that branch, but it is a **dead branch** — the guard only enters the branch when `state.refreshing` is false, so "Refreshing…" is never rendered.) (2) **error**`state.error` non-null → error card; this check runs even while a refresh is in flight. (3) **empty data**`isEmpty(data) && !state.loading` → "No data available" card. While a refresh is in flight with data already present (`refreshing`, no `loading`), the guard returns `null` and the page keeps rendering the existing content — no spinner.
### Data Display ### Data Display
@@ -955,10 +1139,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card. Empty-state placeholder card.
#### `Card({ header, children, cls, title })` #### `Card({ header, children, cls, title, key })`
Card container with optional header. `cls` appends a class to the outer Card container with optional header. `cls` appends a class to the outer
`div.card`; `title` sets a tooltip on the outer div. `div.card`; `title` sets a tooltip on the outer div; `key` sets the VNode key.
#### `ConfirmDelete(props)` #### `ConfirmDelete(props)`
@@ -992,6 +1176,8 @@ ConfirmDelete({
Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`. Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`.
**200-with-errors handling:** batch endpoints (e.g. `/api/status/apply-all`) can return HTTP 200 with an `errors` map when some operations failed, so `resp.ok` alone is not a success signal. When the `errors` map is non-empty, an error toast (`'Failed: <subsystem> — <reason>; …'`, 8000 ms) is shown and the success toast is **suppressed**; `onSuccess` still runs.
```javascript ```javascript
ActionButton({ ActionButton({
url: '/api/dhcp/apply', url: '/api/dhcp/apply',
@@ -1022,7 +1208,7 @@ ActionButton({
| `url` | API URL | | `url` | API URL |
| `method` | HTTP method (default: `'POST'`) | | `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` or `undefined` for no body | | `body` | `() => body` or `undefined` for no body |
| `label` | Button text | | `label` | Button text (default: `'Action'` when no `label` and no toggle pair is given) |
| `labelOn` / `labelOff` | Toggle labels when `condition` is true/false | | `labelOn` / `labelOff` | Toggle labels when `condition` is true/false |
| `condition` | Toggle condition for `labelOn`/`labelOff` | | `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message | | `successMsg` | Success toast message |
@@ -1188,23 +1374,48 @@ shared expandable-subsystems modal. Both fetch `/api/status/pending` to
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall, populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
dnsmasq, nginx, wireguard, networkd). dnsmasq, nginx, wireguard, networkd).
**Module exports:** `ApplyConfirm`, `CancelConfirm`, `SUBSYSTEM_LIST`
(`[{ key, label }]` row order), `isPending(ss)` (true when a subsystem result
carries `needs_apply` or `pending_changes`), `buildRows(pendingData, expanded)`
(VNode rows for the modal, given pending data and an expandable-state object),
and `applyResultToasts(data, successMsg)` — returns `{ error, success }` for an
apply-all response: a non-empty `errors` map yields an error string and
suppressed success; otherwise success is `successMsg` when anything was applied.
#### `ApplyConfirm(props)` #### `ApplyConfirm(props)`
Button that opens the confirmation modal listing pending subsystems, then Button that opens the confirmation modal listing pending subsystems, then
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a POSTs `/api/status/apply-all`. When `props.pending` is false it renders an
disabled "synced" button that toasts on click. enabled **"synced" button** (not disabled) that toasts
`successMsg || 'All synced'` (type `'info'`) on click.
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`, **Force apply:** when the firewall has pending changes (the only subsystem
`successMsg`, `refresh` (legacy, ignored). whose apply honours `force`), the modal shows a **"Force apply" checkbox**
("overrides firewall safety guards, e.g. removing an interface from all zones
or removing https/ssh from the default zone"). Ticking it sends
`{ force: true }` as the request body to `/api/status/apply-all`.
**Toasts:** a 200 response may still carry an `errors` map (firewall safety
guards refused a change) — then an error toast (`'Apply failed for: …'`,
8000 ms) is shown and the success toast suppressed; otherwise a success toast
(default `'All changes applied'`). HTTP failures toast the error.
State-store models update from the daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `pending` (bool), `label` (default `'Apply'`), `syncedLabel`
(default `'Synced'`), `cls` (default `'btn btn-primary'` pending /
`'btn btn-outline'` synced), `successMsg` (default `'All changes applied'`),
`refresh` (legacy, ignored).
#### `CancelConfirm(props)` #### `CancelConfirm(props)`
Button that opens the confirmation modal listing the subsystems that Button that opens the confirmation modal listing the subsystems that
would be reverted ("Restores the listed subsystems to their last applied would be reverted ("Restores the listed subsystems to their last applied
configuration, discarding changes saved since the last apply"), then configuration, discarding changes saved since the last apply"), then
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem POSTs `/api/status/cancel-all`. The success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map; errors from the details when the response has a non-empty `skipped` map — in that case it is
response are toasted separately. State-store models update from the toasted as `'warning'` for 8000 ms, otherwise as `'success'`; errors from the
response (`'Cancel failed for: …'`) are toasted separately as `'error'`
(8000 ms). State-store models update from the
daemon's WS delta — no explicit `modelFetch`. daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `label` (default `'Cancel All Changes'`), `cls` **Parameters:** `label` (default `'Cancel All Changes'`), `cls`
@@ -1216,15 +1427,33 @@ CancelConfirm({ cls: 'btn btn-sm btn-danger' })
### Modal ### Modal
#### `openModal(renderFn)` #### `openModal(renderFn | vnodes)`
Open a modal dialog. `renderFn` receives the modal content element: Open a modal dialog. Two forms:
```javascript - **renderFn**`renderFn(contentEl, idx) => void`; the second argument is the
openModal((inner) => { modal's queue index. Modals render directly into `#modal-root` via DOM
inner.innerHTML = '<h2 class="modal-title">Details</h2>…'; manipulation (not the VDOM diff), so `innerHTML` works here:
});
``` ```javascript
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});
```
- **VNode / VNode[]** — rendered into the content element via `modalVNodes`.
**Overlay click:** clicking the overlay (outside the modal box) closes the
topmost modal — unless it is currently processing (async operation in flight),
in which case the click is ignored. If the modal contains form inputs
(`formModal` sets this), the click first asks **"Discard changes?"** and
aborts on a declined confirm.
#### `modalVNodes(inner, vnodes)`
Render Hoover VNodes (single or array) into a modal content element. The modal
content is cleared and repainted each time — VNodes are **not** diffed across
modal re-renders (modals are transient, which avoids lifecycle baggage).
#### `closeModal([idx])` #### `closeModal([idx])`
@@ -1234,6 +1463,19 @@ Close a modal. Without argument, closes the topmost modal.
Close all open modals. Close all open modals.
#### `refreshModals()` / `isModalProcessing([idx])` / `setModalProcessing(flag, [idx])`
Modal processing API:
- `refreshModals()` — re-renders all open modals in place (re-runs each
`renderFn`). Used by long-lived modals that update in place; the processing
spinner on action buttons appears via a re-render after
`setModalProcessing(true)`.
- `isModalProcessing([idx])` — true when the topmost (or specified-index)
modal has an active async operation.
- `setModalProcessing(flag, [idx])` — set/clear that flag. `apiSubmit` and
`formAction` manage it for you.
#### `formModal(inner, title, fields, actions)` #### `formModal(inner, title, fields, actions)`
Render a standard modal form inside the modal content element. Render a standard modal form inside the modal content element.
@@ -1242,22 +1484,37 @@ Render a standard modal form inside the modal content element.
```javascript ```javascript
{ label: 'Name', id: 'name', placeholder: 'Enter name' } { label: 'Name', id: 'name', placeholder: 'Enter name' }
{ label: 'Type', id: 'type', tag: 'select', options: [['a', true], 'b', 'c'] } { label: 'Type', id: 'type', tag: 'select', options: [['a', 'Label A'], 'b', { group: 'More', options: ['c'] }] }
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' } { label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
{ label: 'Enabled', id: 'enabled', type: 'checkbox', checked: true }
{ label: 'Tags', id: 'tags', tag: 'select', multiple: true, options: [...] }
``` ```
- `tag`: `'input'` (default), `'select'`, `'textarea'` - `tag`: `'input'` (default), `'select'`, `'textarea'`
- For `select`: `options` is an array of strings or `[value, selected]` tuples - `type`: input `type` attribute (e.g. `'checkbox'`, `'number'`; `'text'` is omitted)
- `checked`: renders the `checked` attribute (checkboxes)
- `multiple`: renders a `<select multiple>`
- For `select`, `options` is an array of:
- strings (`'<option value="x">x</option>`),
- `[value, selectedBoolean]` tuples (boolean second element → `selected`), or
`[value, labelString]` tuples (non-boolean second element → option label), or
- `{ group, options }` objects → `<optgroup>` (nested options follow the
string / `[value, label]` formats)
- `value` is pre-populated value - `value` is pre-populated value
**Action shape:** **Action shape:**
```javascript ```javascript
{ label: 'Save', cls: 'btn-primary', action: 's', handler: () => { … } } { label: 'Save', cls: 'btn-primary', action: 's', processing: true, handler: () => { … } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() } { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
``` ```
The `action` field becomes a `data-action` attribute used for button lookup. - `action` becomes the button's `id` (`am-<action>-<idx>`), used for button lookup.
- `processing: true` — the button renders **disabled with a spinner** while the
modal is in a processing state (managed by `setModalProcessing`), and its
click does not inline-disable; the handler's `refreshModals()` re-render
recreates the button in the processing state. Handlers without the flag are
inline-disabled with a spinner when clicked.
#### `QuickModal(props)` #### `QuickModal(props)`
@@ -1292,10 +1549,11 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
| `submit.method` | HTTP method (default: `'POST'`) | | `submit.method` | HTTP method (default: `'POST'`) |
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) | | `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
| `submit.validate` | `(body) => string \| null`, validation function | | `submit.validate` | `(body) => string \| null`, validation function |
| `submit.successMsg` | Success toast message or `(data) => string` | | `submit.successMsg` | Success toast message or `(data) => string` (default: `'Done'`) |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. | | `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit | | `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
| `submitLabel` | Submit button label (default: `'Submit'`) | | `submitLabel` | Submit button label (default: `'Submit'`) |
| `postRender` | Optional `(inner, data) => void`, run after `formModal` has rendered — for appending extra content to the modal body |
#### `MultiSelectModal(props)` #### `MultiSelectModal(props)`
@@ -1340,6 +1598,28 @@ Selection, the search query, and the advanced flag are held in a closure per
open call, so `refreshModals()` re-renders (e.g. the processing spinner) open call, so `refreshModals()` re-renders (e.g. the processing spinner)
re-apply the current state instead of losing it. re-apply the current state instead of losing it.
### Auth & QR Components
`components/auth.js` — thin ceremony layer over the auth model (token
storage / refresh / session state lives in `auth_model.js`; this module
never manages state):
| Function | Description |
|---|---|
| `logout()` | POSTs `/api/auth/logout` (best-effort, token + `refresh_token` in body), then drives the auth model to the terminal all-nulls state — storage clear, `#/login` redirect, `auth:logout` event |
| `doLogin(data, redirectPath = '/dashboard')` | Drives the auth model through the `login` action (`onSuccess` persists the session, schedules the TTL refresh, fires `auth:login`), then navigates to `redirectPath` |
| `webauthnSupported()` | `true` when `window.PublicKeyCredential` exists |
| `startRegistration(registrationOptions)` | Runs the WebAuthn registration ceremony (`navigator.credentials.create`); returns the credential response as a JSON-serializable dict (`id`, `rawId`, `type`, `response`) for the server. Throws when unsupported |
| `startAuthentication(authenticationOptions)` | Runs the WebAuthn authentication ceremony (`navigator.credentials.get`); returns the assertion response as a JSON-serializable dict. Throws when unsupported |
`components/qr.js` — QR code rendering (uses the vendored `qrcode-svg`):
| Function | Description |
|---|---|
| `qrSVG({ text, size = 200, margin = 2, ecLevel = 'Q', logo, logoSize = 40, color = '#000000', background = '#ffffff' })` | Returns an SVG **markup string** for the QR code; optional base64-data-URL `logo` overlay (white padding rect behind the image). Empty string when `text` is missing |
| `QRCodeVNode({ text, size, logo, logoSize })` | VNode wrapper around `qrSVG` (renders the SVG via `innerHTML`; placeholder text when empty) |
| `LogoUpload({ id, onChange })` | File-input widget that reads the selected image as a base64 data URL and calls `onChange(dataUrl)` |
### Toast ### Toast
#### `ToastContainer()` #### `ToastContainer()`
@@ -1415,8 +1695,22 @@ Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` wher
| `enc(s)` | URL-encode a string (`encodeURIComponent`) | | `enc(s)` | URL-encode a string (`encodeURIComponent`) |
| `$val(id)` | Get `value` of `document.getElementById(id)` | | `$val(id)` | Get `value` of `document.getElementById(id)` |
| `parseZones(data)` | Parse zone data from API responses into a flat string array | | `parseZones(data)` | Parse zone data from API responses into a flat string array |
| `fmtBytes(bytes)` | Format a byte count as a human-readable string (`'1.4 MB'`, `'0 B'`) |
| `csvToArr(value)` | Split a comma-separated string into trimmed, non-empty values (empty input → `[]`) |
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob | | `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
## Schema (`schema.js`)
Client-side awareness of the daemon state store (shapes in `docs/state-model.md`):
- **`SUBSYSTEMS`** — `{ <subsystem>: { defaults } }`. The `defaults` object
initializes `model.data` via `defaultData` at `modelRegister` time so pages
don't need null guards during the first render (before the WS snapshot or
HTTP fallback delivers real data). The WebSocket streams these exact shapes.
- **`POLL_INTERVALS`** — client-side mirror of the daemon's per-subsystem
refresh cadence in seconds (`system: 1`, `wireguard`/`dnsmasq`/`networkd: 10`,
`firewall: 30`, `nginx: 60`, `acme: 300`) — for "last updated" displays.
## Static Asset Caching ## Static Asset Caching
The server handles caching headers for static assets. Browser cache invalidation is managed The server handles caching headers for static assets. Browser cache invalidation is managed
@@ -1427,7 +1721,8 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions ## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`. - **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`. - **Tab title**: Pages declare `title: '<Page> - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly.
- **Model-first data loading**: Model-backed pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. The two exceptions are `users.js` and `passkeys.js`, which fetch page-local data with `apiFetch` in `load()` against a module-level reactive state (see **Module-level shared reactive state**).
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode. - **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags. - **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`). - **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`).
+114 -45
View File
@@ -6,13 +6,17 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
## Architecture Overview ## Architecture Overview
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx (TLS termination only — management authentication is a Flask-layer JWT, not nginx basic auth; individual proxy domains may optionally configure their own basic auth). Vacuum Wall is built around six integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket; the daemon also streams real-time state over a local WebSocket (127.0.0.1:9091) — a full `snapshot` on connect, then per-subsystem `versions` (structural) and `tick` (volatile-only) deltas — so the UI auto-refreshes without HTTP polling. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management; and the authentication subsystem manages users, passkeys, and JWT sessions. Certificate management is tracked as a standalone state subsystem with its own API. In total, `lib/state.py` tracks 7 state subsystems. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx (TLS termination only — management authentication is a Flask-layer JWT, not nginx basic auth; individual proxy domains may optionally configure their own basic auth).
## Subsystems ## Subsystems
### Firewall ### Firewall
The firewall uses firewalld's zone model for traffic control. Network interfaces are assigned to zones such as external, internal, VPN, and trusted. Rules and services define which traffic is allowed between zones. Source NAT (masquerade) enables RFC 1918 networks to reach the internet through the external interface. Destination NAT rules provide port forwarding, exposing internal services to external networks on configurable ports. The firewall uses firewalld's zone model for traffic control. Network interfaces are assigned to zones such as external, internal, and trusted, plus a per-access-class `vpn-<class>` zone for each WireGuard access class (managed by the WireGuard sync). Zones carry an optional per-zone `target` (accept/drop/reject), and rules express fine-grained policies via services, port rules, and rich rules. Source NAT (masquerade) enables RFC 1918 networks to reach the internet through the external interface. Destination NAT rules provide port forwarding, exposing internal services to external networks on configurable ports.
**Interface-coverage invariant.** Every network-managed interface (`lo`/`wg*` excluded) must be covered by a zone in the firewall config or declared in the top-level `unmanaged` list. The invariant is enforced at save time (400) and at apply time (409; `{"force": true}` overrides); live drift is advisory only and surfaced as `uncovered_interfaces` in state.
**Pending-changes model.** Edits saved to a config are not applied until the operator applies them. Each subsystem exposes `pending_changes` plus a `pending_diff` of the changed fields, aggregated at `GET /api/status/pending`. `POST /api/status/apply-all` applies pending changes in dependency order (networkd → firewall → wireguard → dnsmasq → nginx); `POST /api/status/cancel-all` reverts all pending edits to the last-applied config.
### DHCP/DNS ### DHCP/DNS
@@ -20,26 +24,38 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
### SSL Proxy ### SSL Proxy
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (Let's Encrypt by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention. The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and the configured ACME provider (the CA is config-driven; the code default is Let's Encrypt). The configuration is a three-part model: named `backends`, `domains` that reference them, and a global `ssl` settings block. Each domain's paths resolve against its named backend's path table, and a builtin `webui` backend serves the management interface (Flask on 127.0.0.1:9090 plus the WebSocket on 127.0.0.1:9091). Backends are managed through the web UI (list, add, update, remove). Proxy domains may additionally gate paths with per-domain basic auth via a generated `.htpasswd` file — never on the management domain, which relies on the Flask-layer JWT. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
### Network (systemd-networkd) ### Network (systemd-networkd)
The networkd subsystem manages static IP configuration for network interfaces via systemd-networkd. It renders declarative JSON configuration into per-interface `.network` INI files (`50-<name>.network`), supporting static addresses, routes, DNS, DHCP clients, link settings, and all `[Address]`, `[Route]`, `[DHCPv4]`, `[DHCPv6]`, and `[Link]` section keys. When the full apply runs, public DNS servers from networkd configs are auto-synced to dnsmasq's upstream resolvers. Helper endpoints can infer candidate DHCP ranges from static IPs and suggest firewalld zone assignments based on interface role. The networkd subsystem manages static IP configuration for network interfaces via systemd-networkd. It renders declarative JSON configuration into per-interface `.network` INI files (`99-<name>.network`), supporting static addresses, routes, DNS, DHCP clients, link settings, and all `[Address]`, `[Route]`, `[DHCPv4]`, `[DHCPv6]`, and `[Link]` section keys. When the handler applies an interface, it removes lower-priority conflicting `.network` files from the system directory. When the full apply runs, public DNS servers from networkd configs are auto-synced to dnsmasq's upstream resolvers. Helper endpoints can infer candidate DHCP ranges from static IPs and suggest firewalld zone assignments based on interface role.
### WireGuard ### WireGuard
WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage. WireGuard support provides server-side VPN tunnel management. Tunnels are organized into **access classes**: each class owns a `wg-<class>` interface, a `vpn-<class>` firewall zone, a dedicated subnet, listen port, and keypair, plus a `lan_access` flag controlling whether its peers can reach the LAN. Two classes exist by default (`full`, with LAN access, and `internet`, without). Classes are managed through CRUD endpoints (add, update, delete, reorder, generate keys). Peers are assigned to a class and added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage.
### Authentication
Authentication is a first-class subsystem. Users, per-subsystem read/rw permissions, Argon2id password hashes, and optional passkeys (WebAuthn/FIDO2) are stored in a SQLite database (`data/auth.db`), reached through an abstract database layer that never exposes raw SQL. Sessions use JWT access + refresh tokens: each user holds their own HS256 signing secret, and revoked tokens are blacklisted by `jti`. A builtin `admin` user is seeded at bootstrap. The subsystem exposes `/api/auth/*` endpoints and the login, users, and passkeys pages.
### Certificates (ACME)
Certificate management is a standalone state subsystem with its own API (`/api/certs/*`). acme.sh issues and renews certificates for proxy domains against the configured CA provider; self-signed certificates can be generated for domains without an ACME account, and ACME accounts can be registered or deactivated. A systemd timer runs periodic renewals, and certificate state (issuance, expiry) is collected like any other subsystem.
## Tech Stack ## Tech Stack
- Debian 13 (trixie) target platform - Debian 13 (trixie) target platform
- Python 3.13+, Flask 3.x for web management - Python 3.13+, Flask 3.x for web management
- aiohttp (daemon server) + requests-unixsocket (Unix-socket client)
- firewalld (nftables backend) - firewalld (nftables backend)
- systemd-networkd (ip-lladdr, networkctl) - systemd-networkd (networkctl)
- nginx 1.26+ - nginx
- dnsmasq - dnsmasq
- WireGuard tools (wireguard-tools) - WireGuard tools (wireguard-tools)
- acme.sh for ACME certificate management (Let's Encrypt by default) - acme.sh for ACME certificate management (CA provider config-driven; code default Let's Encrypt)
- SQLite (auth database)
- PyJWT (JWT sessions), argon2-cffi (Argon2id password hashing), webauthn (passkeys), passlib (htpasswd only)
- htm.js (vendored JS tagged-template HTML adapter)
## Quick Start ## Quick Start
@@ -58,65 +74,107 @@ After installation, access the management interface at `https://<hostname>.local
## Project Structure ## Project Structure
``` ```
├── README.md # Project overview
├── AGENTS.md # Agent instructions
├── .gitignore
├── pyproject.toml # Project metadata + dependencies
├── scripts/ # Utility scripts ├── scripts/ # Utility scripts
│ ├── install.sh # Deployment script (renders Jinja2 templates) │ ├── install.sh # Deployment script (renders Jinja2 templates)
── update-vendor.sh # Download vendored libraries (acme.sh, htm) ── update-vendor.sh # Download vendored libraries (acme.sh, htm)
├── pyproject.toml # Project metadata + dependencies │ ├── bootstrap_auth.py # Auth DB bootstrap (creates the operator user)
├── .venv/ # Python virtual environment │ └── restart-services.sh # Restart installed system services
├── config/ # Declarative JSON configuration (source of truth) ├── config/ # Declarative JSON configuration (source of truth)
│ ├── firewall/ # Firewall zone & rule config
│ ├── dnsmasq/ # DHCP/DNS config │ ├── dnsmasq/ # DHCP/DNS config
│ ├── network/ # systemd-networkd per-interface config │ ├── network/ # systemd-networkd per-interface config
│ ├── firewall/ # Firewall zone & rule config │ ├── nginx/ # Proxy backend, domain & SSL config
│ ├── nginx/ # Proxy domain & SSL config │ ├── wireguard/ # VPN access-class, interface & peer config
│ ├── wireguard/ # VPN interface & peer config │ ├── acme/ # ACME account settings (email, CA provider)
│ └── acme/ # ACME account settings (email, CA provider) │ └── auth/ # Authentication settings (JWT, WebAuthn)
├── data/ # Runtime artifacts & generated files ├── data/ # Runtime artifacts & generated files
│ ├── auth.db # SQLite auth database (users, passkeys)
│ ├── certs/ # Management-domain TLS keypair
│ ├── daemon.sock # Daemon Unix socket
│ ├── nginx/sites-enabled/ # Generated server blocks │ ├── nginx/sites-enabled/ # Generated server blocks
│ ├── nginx/.htpasswd # Basic-auth entries for proxy domains
│ ├── dnsmasq/fragments/ # User config fragments │ ├── dnsmasq/fragments/ # User config fragments
│ ├── acme/ # ACME certificates │ ├── acme/ # acme.sh home: certs, account, webroot (www/)
│ ├── firewall/ # Pre-apply recovery snapshot │ ├── firewall/rules.json # Pre-apply recovery snapshot
│ ├── logs/ # Application logs │ ├── networkd/ # Generated 99-<name>.network files
│ ├── networkd/ # Generated 50-<name>.network files │ ├── wireguard/ # Generated WireGuard configs
│ └── wireguard/ # Generated WireGuard configs │ └── logs/ # Application logs
├── daemon/ # Privileged background daemon ├── daemon/ # Privileged background daemon
│ ├── server.py # aiohttp server, cache, batch routing, handler registry │ ├── server.py # aiohttp server: endpoint registry (daemon/iface.py), batch routing, WebSocket broadcast (snapshot/versions/tick), state refresh, per-subsystem polling
│ ├── client.py # Sync HTTP client over Unix socket │ ├── client.py # Sync HTTP client over Unix socket
│ ├── iface.py # Single source of truth for daemon API endpoints
│ ├── __main__.py # Module entry point (python -m daemon.server)
│ ├── handlers/ # Privileged operation handlers (all sudo calls) │ ├── handlers/ # Privileged operation handlers (all sudo calls)
│ │ ── network.py # networkd handler (generate + apply) │ │ ── firewall.py # Zone/rich-rule CRUD + apply
├── system/ # System file templates (all Jinja2) │ │ ├── dnsmasq.py # DHCP/DNS config + apply
│ │ ├── nginx.py # Proxy domain/backend + SSL apply
│ │ ├── network.py # networkd handler (generate + apply)
│ │ ├── wireguard.py # Access-class/peer CRUD + tunnel control
│ │ ├── acme.py # Certificate issue/renew/self-signed, account
│ │ ├── auth.py # User/passkey management
│ │ ├── logs.py # Log streaming
│ │ ├── status.py # Pending/apply-all/cancel-all
│ │ ├── system.py # System info & metrics
│ │ └── common.py # Shared handler helpers (sync emit + refresh)
│ └── collectors/ # Read-only per-subsystem state collectors
│ ├── firewall.py # firewall collector
│ ├── dnsmasq.py # dnsmasq collector
│ ├── networkd.py # networkd collector
│ ├── nginx.py # nginx collector
│ ├── wireguard.py # wireguard collector
│ ├── acme.py # acme collector
│ └── system.py # system collector
├── system/ # System file templates (mostly Jinja2)
│ ├── systemd/ # Service and timer unit files │ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service (rendered at install) │ │ ├── vacuum-wall.service # Web UI service (rendered at install)
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install) │ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
│ │ ├── vacuum-wall-acme.timer # Renewal schedule │ │ ├── vacuum-wall-acme.timer # Renewal schedule
│ │ └── vacuum-walld.service # Privileged daemon (rendered at install) │ │ └── vacuum-walld.service # Privileged daemon (rendered at install)
│ ├── sudoers.d/ # Sudo whitelist (rendered at install) │ ├── sudoers.d/ # Sudo whitelist (rendered at install)
│ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim) │ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim, not Jinja)
│ ├── nginx/ # Nginx config templates (rendered at runtime) │ ├── nginx/ # Nginx config templates (rendered at runtime)
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime) │ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
── wireguard*.conf # WireGuard templates (rendered at runtime) ── wireguard.conf # WireGuard server template (rendered at runtime)
│ ├── wireguard-client.conf# WireGuard client template (rendered at runtime)
│ ├── acme-deploy.py # ACME deploy hook (installed verbatim, not Jinja)
│ └── acme-deploy.sh # ACME deploy wrapper (installed verbatim, not Jinja)
├── lib/ # Subsystem abstraction layer ├── lib/ # Subsystem abstraction layer
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip) │ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip, config_hash, stamp_applied, strip_apply_meta, compute_pending, deep_diff, revert_to_applied, validate_interface_name)
│ ├── logging.py # Logging setup │ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings │ ├── firewall.py # firewalld bindings
│ ├── network.py # systemd-networkd rendering & parsing │ ├── network.py # systemd-networkd rendering & parsing
│ ├── dnsmasq.py # DHCP/DNS configuration │ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration │ ├── nginx.py # Reverse proxy configuration (backends model)
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status) │ ├── state.py # In-memory state store (per-subsystem data, version counters, two-layer versions/tick diff, poll intervals, volatile registration); collectors live in daemon/collectors/
│ ├── sync.py # Cross-subsystem event bus │ ├── sync.py # Cross-subsystem event bus
│ ├── acme.py # Certificate management (ACME helpers) │ ├── acme.py # Certificate management (ACME helpers)
│ ├── wireguard.py # VPN tunnel and peer management │ ├── wireguard.py # VPN tunnel and peer management
── system_import.py # Startup reconciler (imports live system configs into JSON) ── system_import.py # Startup reconciler (imports live system configs into JSON)
│ ├── bootstrap.py # Daemon-startup filesystem bootstrap
│ ├── schema.py # TypedDict state schemas
│ ├── auth.py # JWT access+refresh tokens, per-user HS256 secrets, jti blacklist
│ ├── auth_users.py # Multi-user management, per-subsystem read/rw permissions, builtin admin
│ ├── password.py # Argon2id password hashing
│ ├── webauthn.py # Passkey (FIDO2/WebAuthn) support
│ ├── db.py # Abstract database layer (opaque query IDs)
│ └── db_sqlite.py # SQLite backend (data/auth.db)
├── webui/ # Flask web application ├── webui/ # Flask web application
│ ├── server.py # Application entry point │ ├── server.py # Application entry point
│ ├── api/ # REST API route modules (blueprints) │ ├── api/ # REST API route modules (blueprints)
│ │ ├── common.py # Shared API response helpers (_ok, _error) │ │ ├── common.py # Shared API response helpers (_ok, _error)
│ │ ├── firewall.py # Firewall API │ │ ├── firewall.py # Firewall API
│ │ ├── dhcp.py # DHCP/DNS API │ │ ├── dhcp.py # DHCP/DNS API
│ │ ├── proxy.py # Nginx proxy API │ │ ├── proxy.py # Nginx proxy API (domains + backends)
│ │ ├── certs.py # Certificate API │ │ ├── certs.py # Certificate API
│ │ ├── wireguard.py # WireGuard API │ │ ├── wireguard.py # WireGuard API
│ │ ├── network.py # Networkd API │ │ ├── network.py # Networkd API
│ │ ── logs.py # Logs API │ │ ── logs.py # Logs API
│ │ ├── auth.py # Authentication API
│ │ └── status.py # Status API (pending/apply-all/cancel-all)
│ └── static/ # SPA (index.html, app.js, style.css) │ └── static/ # SPA (index.html, app.js, style.css)
│ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components) │ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
│ │ ├── index.js # Barrel export of all public APIs │ │ ├── index.js # Barrel export of all public APIs
@@ -128,22 +186,32 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── websocket.js │ │ ├── websocket.js
│ │ ├── api.js │ │ ├── api.js
│ │ ├── helpers.js │ │ ├── helpers.js
│ │ ── components/ # Layout, data display, modal, toast │ │ ── html.js # htm.js tag adapter
└── pages/ # Page modules (each defines a route via definePage) │ ├── model.js # Reactive model store
│ │ ├── auth_model.js# Auth session model
│ │ ├── dirty.js # Dirty-state tracking
│ │ ├── schema.js # Schema validation helpers
│ │ └── components/ # applyconfirm, auth, data, layout, modal, qr, toast
│ └── pages/ # 15 page modules (each defines a route via definePage):
│ # dashboard, zones, rules, nat, interfaces, dhcp,
│ # proxy, backends, certs, wireguard, logs, login,
│ # users, passkeys, notfound
├── vendor/ # Vendored scripts and JS libraries ├── vendor/ # Vendored scripts and JS libraries
│ ├── acme.sh # ACME certificate client │ ├── acme.sh # ACME certificate client
── htm.js # JS tagged-template HTML adapter ── htm.js # JS tagged-template HTML adapter
├── docs/ # Documentation │ └── qrcode-svg-1.1.0.js # QR code generation (SVG)
│ ├── overview.md # This file ├── tests/ # Test suites
│ ├── deployment.md │ ├── test_*.py # 28 Python modules (pytest; subprocess calls mocked)
── api.md ── test-*.js # 9 JS test modules (hoover framework)
│ ├── security.md └── docs/ # Documentation
├── architecture.md ├── overview.md # This file
├── config.md ├── deployment.md
── hoover.md # Hoover SPA framework ── api.md
└── scripts/ # Utility scripts ├── security.md
├── install.sh # Deployment script (renders Jinja2 templates) ├── architecture.md
── update-vendor.sh # Download vendored libraries (acme.sh, htm) ── config.md
├── state-model.md # State schema, versions/tick diff, pending-changes model
└── hoover.md # Hoover SPA framework
``` ```
## Documentation ## Documentation
@@ -153,4 +221,5 @@ After installation, access the management interface at `https://<hostname>.local
- [Security Model](security.md) - Privilege model and sudo whitelist - [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design - [Architecture](architecture.md) - Detailed subsystem design
- [Configuration](config.md) - Config file formats and locations - [Configuration](config.md) - Config file formats and locations
- [State Model](state-model.md) - State schema, versions/tick diff, pending-changes
- [Hoover Framework](hoover.md) - Frontend SPA framework reference - [Hoover Framework](hoover.md) - Frontend SPA framework reference
+71 -41
View File
@@ -7,11 +7,11 @@ Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI
- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`. - **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`.
- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path. - **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.
ACME certificate operations via `acme.sh` run as the daemon user — not as root. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh runs as the daemon process invoking it, using webroot validation that does not require binding to privileged ports. ACME certificate operations via `acme.sh` run as the daemon user (`{{ USER_DAEMON_NAME }}`) — never as root, and never from the WebUI process (the WebUI never invokes acme.sh directly). The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_DAEMON_NAME }}`. Issuance and renewal triggered from the WebUI are executed by the daemon as its own subprocess, using webroot validation that does not require binding to privileged ports; the only sudo call around acme.sh is the `chmod g+rwX` that reopens group access on the ACME home (see Sudo Whitelist).
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo. This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades. Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process over the Unix socket, which carries **no authentication of its own**: access to it is protected purely by the socket's `0660` mode and shared-group ownership. The JWT handshake exists on the daemon's **WebSocket** endpoint: WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades.
## Communication Between WebUI and Daemon ## Communication Between WebUI and Daemon
@@ -27,28 +27,27 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
| Nginx | `nginx -s reload` | Graceful nginx configuration reload | | Nginx | `nginx -s reload` | Graceful nginx configuration reload |
| Nginx | `nginx -t` | Nginx configuration syntax validation | | Nginx | `nginx -t` | Nginx configuration syntax validation |
| Nginx status | `systemctl is-active nginx` | Check nginx service status | | Nginx status | `systemctl is-active nginx` | Check nginx service status |
| Nginx file ops | `cp * /etc/nginx/*` | Copy rendered config files to system paths | | Nginx file ops | `cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf` | Copy the rendered config include to its system path (pinned source and destination) |
| Nginx file ops | `cp * /etc/nginx/conf.d/*` | Copy rendered config files to system paths | | Nginx file ops | `cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf` | Copy the rendered SSL snippet to its system path (pinned source and destination) |
| Nginx file ops | `cp * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files | | Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files | | Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration | | Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status | | Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists | | Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
| Dnsmasq file ops | `cp * /etc/dnsmasq.d/*` | Copy rendered config files | | Dnsmasq file ops | `cp -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf` | Copy the rendered dnsmasq fragment to its system path (pinned source and destination) |
| Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table | | Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) | | WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management | | WireGuard | `wg *` | WireGuard status and peer management |
| WireGuard file ops | `cp * /etc/wireguard/*` | Copy rendered config files | | WireGuard file ops | `cp -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf` | Copy the rendered WG config to its system path (pinned source and destination) |
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config | | WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
| Certificates | (none) | acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used) | | Certificates | `chmod g+rwX {{ ACME_HOME }}/*` | Reopen group read/write on ACME home files after acme.sh hardens them to owner-only modes (`normalize_acme_home()`, run before every daemon acme.sh invocation). Files only: setgid directories already grant group rwx |
| Network queries | `ip -o link show` | List network interfaces | | Network queries | `ip -o link show` | List network interfaces |
| Network queries | `ip -o addr show` | List IP addresses on interfaces | | Network queries | `ip -o addr show` | List IP addresses on interfaces |
| Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) | | Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) |
| Networkd | `networkctl status *` | Query interface status from networkd | | Networkd | `networkctl status *` | Query interface status from networkd |
| Networkd | `networkctl reload` | Reload networkd for all interfaces | | Networkd | `networkctl reload` | Reload networkd for all interfaces |
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface | | Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
| Networkd file ops | `cp * /etc/systemd/network/*` | Copy rendered network unit files | | Networkd file ops | `cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/` | Copy rendered network unit files (pinned destination dir, `99-*` source pattern) |
| Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files | | Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files |
| Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists | | Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
| Sysctl | `sysctl -w *` | Set kernel parameters | | Sysctl | `sysctl -w *` | Set kernel parameters |
@@ -58,9 +57,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
Key safety properties: Key safety properties:
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`). - Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
- Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none grant shell access or arbitrary command execution. - Full-argument wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`, `sysctl -w *`, `journalctl --unit=* -n *`, `networkctl status *`, `networkctl reconfigure *`, `ip -o addr show *`); the remaining wildcard entries target fixed destination paths with a filename pattern (`cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/`, `rm /etc/systemd/network/*.network`, `chmod g+rwX {{ ACME_HOME }}/*`). All file-copy entries are pinned to a single source file under the daemon-owned `/run/vacuum-wall` runtime dir and a single destination path. None of the entries grant shell access or arbitrary command execution.
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement. - `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement.
- The sudoers file is rendered from a Jinja2 template at install time, substituting the configured user name. - The sudoers file is rendered from a Jinja2 template at install time, substituting the configured `USER_DAEMON_NAME` and `ACME_HOME` variables (the install also renders `USER_NAME`, `USER_GROUP`, and `PROJECT_DIR` for the systemd unit templates).
## Daemon Client Path Resolution ## Daemon Client Path Resolution
@@ -74,46 +73,53 @@ The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directl
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers. JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary. Flask sets a full `Content-Security-Policy` (all sources locked to `'self'` with `img-src 'self' data:`) and `X-Content-Type-Options: nosniff` on **every** response via an `after_request` hook — the CSP includes `frame-ancestors 'none'`, `base-uri 'self'`, and `form-action 'self'`. `X-Frame-Options` and HSTS are absent on the management domain; clickjacking protection comes from the CSP `frame-ancestors 'none'` directive instead. The SPA relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.
The auth-exempt public path list covers the SPA root, static and vendor files, `POST /api/auth/login`, `POST /api/auth/refresh`, and the two WebAuthn authentication endpoints (`POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`). nginx writes the management domain's traffic to dedicated `wall_mgmt_access.log` / `wall_mgmt_error.log` files; non-management domains get per-domain `<domain>_access.log` / `<domain>_error.log` logs.
### Proxy Domains ### Proxy Domains
Every proxied domain configured in Vacuum Wall enforces: Proxied domains **without** a management path enforce, at the nginx server level:
- **HTTP-to-HTTPS redirect** — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent. - **HTTP-to-HTTPS redirect** rendered only when the domain has `force_ssl` enabled. All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent (the HTTP server block also serves the ACME HTTP-01 challenge location `/.well-known/acme-challenge/` before the redirect).
- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age and `includeSubDomains` to prevent downgrade attacks. - **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with `max-age=31536000; includeSubDomains` to prevent downgrade attacks.
- **Security headers** on all proxied responses: - **Security headers** on all responses from the domain:
- `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing. - `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing.
- `X-Frame-Options: DENY` — Prevents clickjacking via iframes. - `X-Frame-Options: DENY` — Prevents clickjacking via iframes.
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering. - `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage. - `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
Domains that carry a management path get none of the above — the management SPA receives its security headers from Flask instead (see Management Interface).
**Basic auth on proxy domains**: a domain-level `auth` block renders `auth_basic` + `auth_basic_user_file` on the whole server block, and per-path `auth` blocks apply it to individual proxied paths. The generated `.htpasswd` files hash passwords with **SHA-256 crypt** (mode 0640). The management domain never gets `auth_basic` — management auth is the Flask-layer JWT middleware.
Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients. Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
### JWT Authentication Lifecycle ### JWT Authentication Lifecycle
JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is: JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued. 1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (5 min — the fresh-install bootstrap writes `access_token_ttl: 300`; TTLs are configurable in `config/auth/config.json`) and a refresh token (7 days) are issued, each bound to a fresh `session_id`.
2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions. 2. **Validation**: Every API request to Flask includes `Authorization: Bearer <token>` and an `X-Session-Id` header. The `before_request` middleware returns 401 without the session header, validates the token signature, checks expiry, verifies the `X-Session-Id` matches the token's `session_id` claim (binding the token to the browser session that created it), queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page. 3. **Auto-refresh**: Before the access token expires, the frontend's `scheduleRefresh()` timer (fires at TTL 60s, minimum 30s) calls `POST /api/auth/refresh` with the refresh token and `session_id` — the refresh endpoint requires a matching `session_id` so a stolen refresh token cannot be rotated without the originating session. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`. 4. **Revocation**: The primary revocation mechanism is **per-user JWT signing-secret rotation**: tokens are signed with a per-user secret (not a global key), and changing the password or resetting it, or changing permissions, rotates the user's secret (deleting the user removes the secret entirely), immediately invalidating every existing access and refresh token. The affected user's active refresh token `jti` is additionally inserted into `token_blacklist`, as is the access token's `jti` on logout (`POST /api/auth/logout`). On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (every 60s) and by a probabilistic check inside `blacklist_token()`.
Token theft protection: Token theft protection:
- Short-lived access tokens (15 min) limit the window of exploitation - Short-lived access tokens (5 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change - Per-user signing-secret rotation on password/permission change plus the token blacklist prevent reuse after credential changes or logout
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain - `X-Session-Id` binding ties access and refresh tokens to the originating browser session
- XSS mitigations: CSP headers set by Flask on every response
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk. **WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 5-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
### WebAuthn Security ### WebAuthn Security
WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties: WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:
- **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain. - **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain.
- **Private key protection**: The private key never leaves the authenticator device. The server only stores the public key and signature counter in the `webauthn_creds` table. - **Private key protection**: The private key never leaves the authenticator device. The server stores the `username`, `credential_id`, display `name`, `transports`, public key, and signature counter in the `webauthn_creds` table.
- **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention). - **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
- **RP configuration**: `rp_id` and `origin` are configurable per deployment in `config/auth/config.json`. - **RP configuration**: `rp_id` and `origin` are **derived from the request** (`X-Forwarded-Proto`/`X-Forwarded-Host`) and validated against the live management domains, so credentials are bound to the domain the user actually reached. The `webauthn` section of `config/auth/config.json` holds only `enabled` and `rp_name` (the installer writes `rp_id`/`origin` on fresh install, but the runtime never reads them).
- **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out. - **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.
### Header-Only Authentication and CSRF ### Header-Only Authentication and CSRF
@@ -125,9 +131,8 @@ The API exclusively reads the `Authorization` header — never cookies. This arc
- No SameSite, double-submit, or origin checking needed - No SameSite, double-submit, or origin checking needed
**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include: **XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include:
- CSP headers on the management domain (configured in nginx) - CSP headers set by Flask's `after_request` hook on every API/SPA response (nginx adds a separate `default-src 'none'` CSP only on `/static/`)
- `X-XSS-Protection` header - Short-lived access tokens (5 min) with secret rotation and blacklist on logout
- Short-lived access tokens (15 min) with blacklist on logout
### TLS Configuration ### TLS Configuration
@@ -138,6 +143,15 @@ The default nginx SSL configuration enforces modern TLS only:
- **ssl_prefer_server_ciphers** defaults to `off` (client chooses). - **ssl_prefer_server_ciphers** defaults to `off` (client chooses).
- **Session settings**: `ssl_session_timeout 1d`, `ssl_session_cache shared:TLS:10m`, `ssl_session_tickets off`. - **Session settings**: `ssl_session_timeout 1d`, `ssl_session_cache shared:TLS:10m`, `ssl_session_tickets off`.
### Brute-Force Protection
Login and WebAuthn authentication attempts are rate-limited in-process with sliding windows that count failures only (a success resets the bucket):
- **Password login**: 10 failures per 300s, tracked per **username and per client IP** (`X-Real-IP`).
- **WebAuthn**: 5 failures per 600s, tracked per username and per client IP.
To prevent username enumeration, password verification for a nonexistent user runs a dummy Argon2id verification against a pre-computed hash, keeping timing uniform. The limiters are in-memory; counts reset on daemon restart (SIGHUP reload, process restart).
## Systemd Hardening ## Systemd Hardening
Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system: Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system:
@@ -145,9 +159,12 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
| Directive | Value | Effect | | Directive | Value | Effect |
|---|---|---| |---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths | | `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`); (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it | | `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`, `/run/nginx.pid`), plus `/var/log/nginx` and `/var/log/vacuum-wall` (daemon); (WebUI only) `config/`, `data/` subdirs and `/var/log/vacuum-wall` | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it |
| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop | | `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop |
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself) | | `RuntimeDirectoryMode` | `0750` (daemon only) | Group-readable runtime dirs (the shared group owns them) |
| `LogsDirectory` | `vacuum-wall` (both units) | Creates `/var/log/vacuum-wall` owned by the service user before namespace setup |
| `ExecReload` | `/bin/kill -HUP $MAINPID` (WebUI only) | SIGHUP triggers the WebUI's auto-reload (reloads `webui.*`/`lib.*` modules, then restarts via SIGTERM); the daemon unit has no `ExecReload` |
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` (`0750`) and `/run/nginx.pid` (`0644`) at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself; nginx rewrites the pid file on start) |
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace | | `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` | | `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `PrivateDevices` | `yes` | Hides all device files under `/dev` | | `PrivateDevices` | `yes` | Hides all device files under `/dev` |
@@ -161,35 +178,48 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
| `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable | | `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable |
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services | | `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities | | `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
| `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` | Restricts available address families | | `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` (WebUI); `AF_UNIX AF_INET AF_INET6 AF_NETLINK` (daemon) | Restricts available address families; the daemon's extra `AF_NETLINK` is its only additional network primitive |
| `IPAddressDeny` | `any` | Drops all network traffic by default | | `IPAddressDeny` | `any` (both units) | Drops all IP traffic by default |
| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach the other process at 127.0.0.1) | | `IPAddressAllow` | `localhost` (both units) | Allows only loopback communication (required to reach the other process at 127.0.0.1) |
The WebUI unit additionally restricts address families and denies all IP traffic except to localhost — it cannot reach any external network interface. Both units use template variables (`{{ USER_NAME }}`, `{{ USER_GROUP }}`, `{{ USER_DAEMON_NAME }}`, `{{ PROJECT_DIR }}`) rendered at install time. Both units deny all IP traffic except to localhost, so neither can reach any external network interface; the only difference in network access is the daemon's extra `AF_NETLINK` family (needed for its netlink queries). Both units use template variables (`{{ USER_NAME }}`, `{{ USER_GROUP }}`, `{{ USER_DAEMON_NAME }}`, `{{ PROJECT_DIR }}`) rendered at install time.
This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the project directory, and no ability to escalate privileges through kernel interfaces. This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no ability to escalate privileges through kernel interfaces, and a strictly bounded write scope: outside the project directory the daemon's unit lists only `/etc/systemd/network`, `/etc/nginx`, `/etc/dnsmasq.d`, `/etc/wireguard`, `/var/log/nginx`, and `/var/log/vacuum-wall` (plus `/tmp` and the `/run` runtime entries), and the WebUI's unit lists only its `config/` and `data/` subdirs and `/var/log/vacuum-wall`.
## Network Security ## Network Security
### Default Deny ### Default Deny
The firewalld default zone policy is set to deny all incoming traffic. Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default. Incoming traffic is denied by default — this is firewalld's built-in behavior for the default zone (no Vacuum Wall code sets a zone target; `apply` only reconciles targets explicitly present in the config). Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default.
### Zone-Based Traffic Isolation ### Zone-Based Traffic Isolation
The `lib/firewall` module is a generic firewalld parser with no hardcoded zone definitions. Zone structure is defined declaratively in `config/firewall/config.json` at runtime. A typical deployment uses: The `lib/firewall` module is a generic firewalld parser; zone structure is defined declaratively in `config/firewall/config.json` at runtime. The only hardcoded zone knowledge is `FIREWALLD_BUILTIN_ZONES` — the 9 zone names firewalld ships by default (`block`, `dmz`, `drop`, `external`, `home`, `host`, `internal`, `public`, `trusted`) — used so built-in zones are never flagged as unmanaged (not in config). The `public` zone is additionally special-cased: its masquerade state is not reconciled by `apply` and cannot be enabled through the masquerade endpoint (see IP Forwarding and NAT). A typical deployment uses:
| Zone | Interface | Purpose | Behavior | | Zone | Interface | Purpose | Behavior |
|---|---|---|---| |---|---|---|---|
| `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo is rate-limited. | | `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo rate-limiting is typical in this deployment but is not enforced by any Vacuum Wall code. |
| `internal` | LAN (e.g., `eth1`) | Trusted local network | DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed. | | `internal` | LAN (e.g., `eth1`) | Trusted local network | DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed. |
| `vpn` | WireGuard (`wg0`) | WireGuard tunnel traffic | Semi-trusted. Firewall rules control which internal services VPN peers can reach. Traffic to the LAN is restricted to specific services and ports. | | `vpn-<key>` | WireGuard (per-access-class interfaces) | WireGuard tunnel traffic, per access class | Semi-trusted. Created and maintained automatically by the WireGuard→firewall sync: one zone per access class with peers, with the class's WG interface assigned, masquerade enabled, a UDP listen-port accept rule, and inter-zone accept rules for internal subnets when the class has `lan_access`. A plain `vpn` zone is managed only as a legacy fallback for peers without an access class. |
| `trusted` / `loopback` | `lo` | Localhost communication | unrestricted; used for the Flask-to-nginx management proxy. | | `trusted` / `loopback` | `lo` | Localhost communication | unrestricted; used for the Flask-to-nginx management proxy. |
| Custom zones | — | DMZ, guest networks, etc. | Additional zones can be created to isolate specific network segments with their own rule sets. | | Custom zones | — | DMZ, guest networks, etc. | Additional zones can be created to isolate specific network segments with their own rule sets. |
### IP Forwarding and NAT ### IP Forwarding and NAT
IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the `internal` zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router. IP forwarding is **not** auto-enabled by Vacuum Wall — `net.ipv4.ip_forward` is one of the allowlisted sysctl keys an operator can set through the network API, and actual traffic flow is controlled by firewalld rules. Masquerade is auto-enabled by the WireGuard→firewall sync **only on VPN zones** (the per-access-class `vpn-<key>` zones and the legacy `vpn` zone), not on `internal`.
The `public` zone is special-cased around masquerade:
- **Refusal**: the masquerade endpoint refuses to enable masquerade on `public` — masquerade must be enabled on `internal` or `vpn` instead.
- **Auto-propagation**: at apply time, if any non-`public` zone has masquerade enabled, `apply` propagates masquerade to the `public` zone (and removes it when no non-public zone needs it), writing the propagated state back to the declarative config. Under the nftables backend, traffic exiting through a `public`-zoned WAN interface hits `public`'s POSTROUTING chain rather than the internal zone's, so NAT would silently fail without this propagation.
### Management Lockout Guard
The firewalld default zone is the catch-all for unassigned interfaces (normally the WAN), so removing both `https` (management access via nginx) and `ssh` (remote recovery) from it would leave no path back except a physical console. The config apply path and the per-zone services endpoint refuse such a change with HTTP `409` unless the request passes `{"force": true}`. The guard fails closed: if the default zone cannot be determined, the operation is treated as a lockout and refused.
### Interface-Coverage Invariant
Every interface managed by the network subsystem (`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 empty — so the check is computed from the config alone with no live-state fallback. Violations are rejected with HTTP `400` at save time (`POST`/`PATCH /firewall/config`) and HTTP `409` at apply time (`POST /firewall/config/apply`, overridable with `force: true`). Live drift is advisory only (the `uncovered_interfaces` state field).
## Input Validation ## Input Validation
+195 -27
View File
@@ -12,26 +12,53 @@ return annotation references them.
- Every collector return carries a top-level `timestamp` (ISO-8601). - Every collector return carries a top-level `timestamp` (ISO-8601).
- Subsystems with a declarative config expose pending state as a status - Subsystems with a declarative config expose pending state as a status
dict: `status: {"pending_changes": bool}`, **except firewall**, which dict: `status: {"pending_changes": bool, "pending_diff": [...]}`,
uses `pending: {config_pending() result}`. **except firewall**, which uses `pending: {config_pending() result}`
(a separate live-drift mechanism, see Firewall below).
- `pending_diff` lists the field-level changes since the last apply;
each entry has the shape:
```
{path: str, action: "added"|"removed"|"changed",
old: <value>|null, new: <value>|null}
```
`path` is a dotted key path; lists of equal length are compared
element-by-element with `[i]` indexes, while any other difference
(including a length change) is reported as a single `changed` entry.
`old` is `null` for added fields, `new` is `null` for removed ones.
Apply-bookkeeping keys (`_last_applied_*`) are ignored. The list is
empty when up to date or when no applied snapshot is recorded.
- A subsystem whose collection failed holds `null`/`None` in the state - A subsystem whose collection failed holds `null`/`None` in the state
store — WS snapshots and deltas skip `null` payloads so a failed store. Null handling differs per push layer:
collector never overwrites good client data. - **snapshot** is NOT filtered server-side — `get_snapshot()` is sent
verbatim, including `null` entries; the client skips `null` payloads
so a failed collector never overwrites good client data.
- **versions** deltas ARE filtered server-side — the daemon skips the
broadcast when the subsystem data is `null`.
- **tick** has no `None` guard (it cannot be `null` in practice: a
tick is only broadcast after a successful poll).
- A **poll failure** does NOT set state to `null` — the stale value is
retained and no broadcast is sent. Only `populate()` (startup and
mutation-triggered refreshes) clears a subsystem to `null` when its
collection fails.
- Config-backed subsystems record their applied baseline inside the config - Config-backed subsystems record their applied baseline inside the config
file itself: `_last_applied_config` (the full merged config at last file itself: `_last_applied_config` (the full merged config at last
apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's
`status.pending_changes` is true when the current (merged) config hash `status.pending_changes` is true when the current (merged) config hash
differs from the recorded hash; `status.pending_diff` lists the field differs from the recorded hash — **or when no hash is recorded at all**
changes since that snapshot. All apply operations (including firewall (the config was never applied). All apply operations (including
`config_apply`) re-stamp the baseline. These bookkeeping keys are firewall `config_apply`) re-stamp the baseline. These bookkeeping keys
internal and stripped from every state/API config payload. Canceling are internal and stripped from every state/API config payload.
pending changes (`POST /api/status/cancel-all`) restores a pending Canceling pending changes (`POST /api/status/cancel-all`) restores a
config file from its snapshot; a subsystem with no recorded baseline pending config file from its snapshot; a subsystem with no recorded
(never applied) is reported as skipped, not reset. Apply-all and baseline (never applied) is reported as skipped, not reset. Apply-all
cancel-all both decide from this last-poll state (an edit saved within and cancel-all operate on freshly re-collected state, not last-poll
the last poll interval may not yet be flagged), and cancel reverts only state: every mutation ends with `emit_and_refresh()` → a synchronous
the declarative config file — live drift (e.g. manual `firewall-cmd`) `refresh_state()` re-collection, so a saved edit is already reflected
survives a cancel. by the time either endpoint runs; only out-of-band changes (e.g. manual
edits) can lag the poll interval. Cancel reverts only the declarative
config file — live drift (e.g. manual `firewall-cmd`) survives a cancel.
## State shape summary ## State shape summary
@@ -39,10 +66,10 @@ return annotation references them.
| Subsystem | Poll | Volatile fields | Top-level keys | | Subsystem | Poll | Volatile fields | Top-level keys |
|---|---|---|---| |---|---|---|---|
| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `service_descriptions`, `uncovered_interfaces`, `zones`, `rich_rules`, `pending`, `timestamp` | | `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `default_zone`, `interfaces`, `available_services`, `service_descriptions`, `uncovered_interfaces`, `zones`, `rich_rules`, `pending`, `timestamp` |
| `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` | | `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` |
| `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` | | `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` |
| `acme` | 300s | *(none)* | `certs`, `email`, `account`, `timestamp` | | `acme` | 300s | *(none)* | `certs`, `email`, `account`, `status`, `timestamp` |
| `wireguard` | 10s | `status.peers[].transfer_received`/`.transfer_sent`/`.latest_handshake` and the same three under `status.classes[].peers[]` | `config`, `status`, `peers`, `timestamp` | | `wireguard` | 10s | `status.peers[].transfer_received`/`.transfer_sent`/`.latest_handshake` and the same three under `status.classes[].peers[]` | `config`, `status`, `peers`, `timestamp` |
| `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` | | `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` |
| `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` | | `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` |
@@ -58,14 +85,17 @@ Top-level `FirewallState`:
{ {
config: {}, // config/firewall/config.json config: {}, // config/firewall/config.json
active_zones: {zone: [iface]}, // zones with assigned interfaces active_zones: {zone: [iface]}, // zones with assigned interfaces
default_zone: str, // firewall-cmd --get-default-zone;
// catch-all zone for interfaces with
// no explicit assignment
interfaces: [ // ip link/addr parsing interfaces: [ // ip link/addr parsing
{name, mac, state, mtu, ips, ipv6, zone} {name, mac, state, mtu, ips, ipv6, zone}
], ],
available_services: [str], // firewall-cmd --get-services available_services: [str], // firewall-cmd --get-services
service_descriptions: {svc: str}, // one-line description from the service_descriptions: {svc: str}, // one-line description from the
// firewalld service XML definitions // firewalld service XML definitions
// (lib/firewall.py get_service_descriptions, // (lib/firewall.py get_service_descriptions,
// cached per process) // cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any LIVE zone — a // lo/wg*) not in any LIVE zone — a
// live-drift advisory (config may still // live-drift advisory (config may still
@@ -93,6 +123,9 @@ Notes:
(IPv6 list is separate). (IPv6 list is separate).
- The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`); - The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`);
`state.rich_rules` is the snake_case top-level re-derivation. `state.rich_rules` is the snake_case top-level re-derivation.
- `pending.pending[]` change dicts have one of two shapes (see
"Firewall pending summary" below): `{zone, type, config, live}` or
`{zone, type, config_count, live_count}`.
## Dnsmasq ## Dnsmasq
@@ -101,7 +134,8 @@ Notes:
config: {}, // config/dnsmasq/config.json, deep-merged config: {}, // config/dnsmasq/config.json, deep-merged
status: { status: {
service_active: bool, config_file_exists: bool, service_active: bool, config_file_exists: bool,
active_leases: int, pending_changes: bool active_leases: int, pending_changes: bool,
pending_diff: [pending_change] // see Shared notes
}, },
leases: [ leases: [
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or "" {expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
@@ -119,7 +153,8 @@ Notes:
{domain, path, backend, online, force_ssl, backend_name, cert, {domain, path, backend, online, force_ssl, backend_name, cert,
[is_management], [is_websocket]} [is_management], [is_websocket]}
], ],
status: {pending_changes: bool}, status: {pending_changes: bool,
pending_diff: [pending_change]},
timestamp: str, timestamp: str,
} }
``` ```
@@ -133,10 +168,30 @@ Notes:
], ],
email: str, email: str,
account: {registered, email, ca, key_length}, account: {registered, email, ca, key_length},
status: {error: str|null}, // null on success; the cert-collection
// failure message otherwise (certs is
// then [])
timestamp: str, timestamp: str,
} }
``` ```
Notes:
- `status.error` is the one failure signal: cert collection failed
(e.g. unreadable `account.conf` after an ownership flip). `certs` is
`[]` while the rest of the state is still collected, so a broken
acme.sh does not blank the whole dashboard; the poll diff detects the
recovery when the error clears.
- Before listing, the collector runs a cheap no-sudo **self-heal probe**:
it walks `ACME_HOME` for files that lost their group-read bit (acme.sh
re-hardens its tree to `chmod 600` on every run) and, only when one is
found, re-runs the sudo permission normalization. The steady-state poll
therefore makes no sudo call.
- When the failure text contains an unreadable `account.conf`
(`Permission denied`), the error is rewritten into an actionable
remediation: `sudo chown <daemon-user>:<group> <ACME_HOME>/account.conf
&& sudo chmod 0640 <ACME_HOME>/account.conf`, then restart
`vacuum-walld`.
## WireGuard ## WireGuard
``` ```
@@ -147,7 +202,10 @@ Notes:
up: bool, // true when ANY managed iface is up up: bool, // true when ANY managed iface is up
interface: {}, peers: [], // legacy single interface (wg0) interface: {}, peers: [], // legacy single interface (wg0)
classes: {class: {up, interface, peers}}, // per wg-<class> classes: {class: {up, interface, peers}}, // per wg-<class>
pending_changes: bool pending_changes: bool,
pending_diff: [pending_change] // entries whose path contains
// "private_key" are dropped, so the
// diff never exposes key material
}, },
peers: [ // config peers, private keys stripped peers: [ // config peers, private keys stripped
{name, public_key, endpoint, allowed_ips, {name, public_key, endpoint, allowed_ips,
@@ -173,7 +231,8 @@ Matches `parse_networkctl_status()` output (lib/network.py) exactly:
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys) gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
state, link} state, link}
}, },
status: {pending_changes: bool}, status: {pending_changes: bool,
pending_diff: [pending_change]},
timestamp: str, timestamp: str,
} }
``` ```
@@ -191,10 +250,119 @@ Metrics only — no config, no pending state.
memory: {total, available, used, used_pct}, // bytes; 0-100 memory: {total, available, used, used_pct}, // bytes; 0-100
swap: {total, used, used_pct}, // bytes; 0-100 swap: {total, used, used_pct}, // bytes; 0-100
traffic: {iface: {rx_bytes, tx_bytes, traffic: {iface: {rx_bytes, tx_bytes,
rx_packets, tx_packets}}, rx_packets, tx_packets}},
timestamp: str, timestamp: str,
} }
``` ```
All four metric fields are volatile (1s tick cadence); structural diffs All four metric fields (`load`, `memory`, `swap`, and the whole
only fire on interface-set changes. `traffic` dict) are volatile, and `timestamp` is excluded from both diff
layers — so a **structural diff can never fire for `system`**. After the
first populate (which always counts as structural and broadcasts a
`versions` envelope), every change is a `tick`.
## Firewall pending summary
`GET /api/firewall/config/pending` returns the state's `pending` dict
plus `pending_summary` — a list of human-readable strings, one per
pending change. Each firewall pending change has one of two shapes:
```
{zone: str, type: str, config: <value>, live: <value>}
// type ∈ {interfaces, services, masquerade, target}
{zone: str, type: str, config_count: int, live_count: int}
// type ∈ {rich_rules, forward_ports}
```
## Apply-all / cancel-all API
All endpoints are proxied to the daemon (`daemon/handlers/status.py`).
Subsystems are processed in dependency order
`SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"]`.
- `GET /api/status/pending` — aggregated pending state:
```
{
firewall: {
needs_apply: bool,
change_count: int,
changes: [{summary: str, detail: ""}],
uncovered_interfaces: [str], // advisory — never counted
coverage_warnings: [str] // advisory — never counted
},
dnsmasq: {pending_changes: bool, summary: str,
changes: [{summary, detail}]},
nginx: {…same…},
wireguard: {…same…},
networkd: {…same…},
total_changes: int,
}
```
- `POST /api/status/apply-all` — applies **only the pending**
subsystems, in `SYS_ORDER`. Body `{"force": true}` is forwarded to the
firewall apply only (it overrides the firewall's management-lockout and
interface-coverage guards; other subsystems ignore it). Response:
`{applied: [subsystem], errors: {label: msg}}`.
- `POST /api/status/cancel-all` — reverts **only the pending**
subsystems' config files to their last-applied snapshot (no
live-system commands run). Response: `{cancelled: [subsystem],
skipped: {label: reason}, errors: {label: msg}}` — `skipped` covers
e.g. "No baseline recorded (never applied)".
- `POST /api/status/refresh` — re-collect state and return the snapshot
for the target subsystems; optional body `{"subsystems": [name, …]}`
filter (all when omitted). **No version bump** — versions advance on
structural poll diffs and mutation-triggered refreshes only.
## Diff & push mechanics
Envelope shapes (daemon → client):
```
{"type": "snapshot", "data": {subsystem: state|null, …}} // on connect
{"type": "versions", "subsystem": str, "data": state} // structural
{"type": "tick", "subsystem": str, "data": state} // volatile
```
- **First poll**: when the previous state is `null` (not yet populated),
the poll counts as structural — the first broadcast after startup is a
`versions` envelope.
- **Two-layer diff** (`lib.state._diff_layers`):
- structural layer — volatile fields zeroed out, `timestamp` removed;
- volatile layer — full data minus `timestamp`, computed only when the
structural layer is unchanged.
- When the structural layer changes, the volatile signal is
**suppressed** (reported unchanged): the `versions` envelope already
carries the full new data, so a tick would be redundant.
- `timestamp` is excluded from **both** layers — a timestamp-only
change never triggers either envelope.
- **Version bumps**: structural polls and mutation-triggered refreshes
(`refresh_state`, default `bump=True`) bump the subsystem version
counter; `tick` broadcasts and `POST /api/status/refresh` never bump.
The counter is not sent over the wire — the envelope itself is the
signal.
- **Poll failure** = no broadcast (stale state retained; see Shared
notes).
- **Client mapping**: `networkd` maps to the `network` model
(`_SUBSYSTEM_TO_MODEL` in `websocket.js`); unknown or retired message
types are ignored.
- **HTTP fallback**: if the WS snapshot has not populated a model within
3 s of page load, the client fetches over HTTP instead —
`POST /api/status/refresh` with `{"subsystems": [name]}` returns the
subsystem state verbatim; a `null` payload fails the fetch and the
model keeps its schema defaults.
- **Interval overrides**: `VACUUM_WALL_POLL_INTERVALS`
(`subsystem:seconds,subsystem:seconds`) is parsed at daemon startup;
entries whose value is `<= 0` or not an integer are skipped with a
logged warning (the subsystem keeps its default interval).
## Frontend schema defaults (stale — follow-up)
`webui/static/hoover/schema.js` holds hand-maintained `defaults` for
every state model (placeholder data before the first WS snapshot / HTTP
fetch). They are currently **stale copies** of this reference: no
firewall `default_zone`, no acme `status`, no `pending_diff` keys. Since
they only seed initial model data and are replaced verbatim by the first
real payload, this is a cosmetic gap — flagged for follow-up (a code
change, not a doc change).
+17 -3
View File
@@ -32,6 +32,11 @@ _ACME_ENVIRON = {
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www" _WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def get_acme_home() -> Path:
"""Resolve the ACME home directory (``ACME_HOME`` env, default ``data/acme``)."""
return Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
def _find_acme() -> str: def _find_acme() -> str:
"""Locate the acme.sh binary on the system. """Locate the acme.sh binary on the system.
@@ -87,7 +92,7 @@ def _run_acme(args: list[str]) -> str:
acme_bin = _find_acme() acme_bin = _find_acme()
# Check for ACME_HOME env var (set by systemd in production) # Check for ACME_HOME env var (set by systemd in production)
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home_env = str(get_acme_home())
cmd: list[str] = [ cmd: list[str] = [
acme_bin, acme_bin,
@@ -143,14 +148,22 @@ def _summarize_acme_output(output: str) -> str:
in the final lines (e.g. "The retryafter=86400 value is too large in the final lines (e.g. "The retryafter=86400 value is too large
(> 600), will not retry anymore."). Strips per-line timestamps and (> 600), will not retry anymore."). Strips per-line timestamps and
the "Please check log file" pointer so the summary stays toast- the "Please check log file" pointer so the summary stays toast-
sized. The full transcript remains in the log and acme.sh.log. sized. A "Permission denied" diagnostic is preserved even when it
is not among the final lines the actionable-error matcher in
daemon/collectors/acme.py keys off it. The full transcript remains
in the log and acme.sh.log.
""" """
lines = [line.strip() for line in output.strip().splitlines() if line.strip()] lines = [line.strip() for line in output.strip().splitlines() if line.strip()]
lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines] lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines]
lines = [line for line in lines if not line.startswith("Please check log file")] lines = [line for line in lines if not line.startswith("Please check log file")]
if not lines: if not lines:
return "(no output)" return "(no output)"
return "; ".join(lines[-2:]) tail = list(lines[-2:])
for line in reversed(lines):
if "Permission denied" in line and line not in tail:
tail.insert(0, line)
break
return "; ".join(tail)
def set_email(email: str) -> None: def set_email(email: str) -> None:
@@ -649,6 +662,7 @@ __all__ = [
"days_until_expiry", "days_until_expiry",
"deploy", "deploy",
"find_cert_dir", "find_cert_dir",
"get_acme_home",
"get_cert_info", "get_cert_info",
"get_cert_paths", "get_cert_paths",
"get_email", "get_email",
+1 -1
View File
@@ -191,7 +191,7 @@ def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.). todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
Returns: Returns:
INI content string ready to write as 50-<name>.network file. INI content string ready to write as 99-<name>.network file.
""" """
lines: list[str] = [] lines: list[str] = []
d = cfg_entry d = cfg_entry
+1 -1
View File
@@ -227,7 +227,7 @@ class NginxState(TypedDict):
Attributes: Attributes:
config: config/nginx/config.json. config: config/nginx/config.json.
domains: Flattened domain entries (one per domain+path). domains: Flattened domain entries (one per domain+path).
status: ``{"pending_changes": bool}``. status: ``{"pending_changes": bool, "pending_diff": list[dict]}``.
timestamp: ISO-8601 collection time. timestamp: ISO-8601 collection time.
""" """
+13 -3
View File
@@ -231,6 +231,16 @@ mkdir -p "$ACME_HOME/deploy"
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh" cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh" chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh" chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh"
# Ensure the daemon user owns acme.sh's runtime conf files (account.conf and
# any per-domain .conf). acme.sh hardens these owner-only (600); if a
# non-daemon user ever (re)creates them the daemon cannot source account.conf
# and every acme.sh call exits 2. The daemon self-heals on the next run, but
# fixing ownership here avoids the initial broken window on fresh installs.
if [ -d "$ACME_HOME" ]; then
find "$ACME_HOME" -maxdepth 1 -type f -name '*.conf' \
-exec chown "$USER_DAEMON_NAME:$USER_GROUP" {} + 2>/dev/null || true
[ -f "$ACME_HOME/account.conf" ] && chmod 0640 "$ACME_HOME/account.conf"
fi
# --- 3. Setup directories --- # --- 3. Setup directories ---
log "Creating config and data directories..." log "Creating config and data directories..."
@@ -243,9 +253,9 @@ mkdir -p /etc/dnsmasq
chmod -R a+rX "${PROJECT_DIR}/webui/static" chmod -R a+rX "${PROJECT_DIR}/webui/static"
# ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work. # ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work.
_d="${PROJECT_DIR}" _d="${PROJECT_DIR}"
while [[ "$d" != "/" && -n "$d" ]]; do while [[ "$_d" != "/" && -n "$_d" ]]; do
chmod a+x "$d" 2>/dev/null || true chmod a+x "$_d" 2>/dev/null || true
d="$(dirname "$d")" _d="$(dirname "$_d")"
done done
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev. # Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
# The top-level .git (directory or worktree pointer file) is left untouched so # The top-level .git (directory or worktree pointer file) is left untouched so
+1 -1
View File
@@ -53,7 +53,7 @@ server {
{% endif %} {% endif %}
{% for ppath, pcfg in paths.items() %} {% for ppath, pcfg in paths.items() %}
{% if pcfg.is_management and ppath == '/' %} {% if pcfg.is_management %}
# SPA static assets — served from disk, no Flask round-trip. # SPA static assets — served from disk, no Flask round-trip.
# no-cache: browsers revalidate every load; unchanged files are 304s. # no-cache: browsers revalidate every load; unchanged files are 304s.
location /static/ { location /static/ {
+112
View File
@@ -185,6 +185,118 @@ test('refresh action rotates tokens; new session_id wins, omitted fields fall ba
assertEq(data.user?.username, 'alice', 'user from response'); assertEq(data.user?.username, 'alice', 'user from response');
}); });
/* ── exp-claim TTL tests ───────────────────────────────────── */
/** Base64url-encode a JSON object (JWT segment builder). */
function b64url(obj) {
return btoa(JSON.stringify(obj))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Build a structurally valid (unsigned) JWT whose exp is offsetSeconds from now. */
function makeJwt(offsetSeconds) {
return [
b64url({ alg: 'HS256' }),
b64url({
sub: 'alice',
exp: Math.floor(Date.now() / 1000) + offsetSeconds,
iat: Math.floor(Date.now() / 1000),
type: 'access',
session_id: 'sess-jwt',
}),
b64url({ sig: true }),
].join('.');
}
/** Most recent defined entry in the captured timer queue. */
function lastTimer() {
for (let i = _timers.length - 1; i >= 0; i--) if (_timers[i]) return _timers[i];
return null;
}
test('check 200: ttl is the token\'s remaining lifetime (exp claim), not the stored full TTL', async () => {
const s = setup({
initialStorage: {
'vw:access': makeJwt(600), // expires in 10 min…
'vw:refresh': 'refresh-old',
'vw:session_id': 'sess-jwt',
'vw:access_ttl': '900000', // …but the stored full TTL says 15 min
},
});
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } },
});
await act('check');
const ttl = getModel('auth').data.ttl;
assert(ttl > 590 * 1000 && ttl <= 600 * 1000,
`remaining ttl (~600s), not the stored 900s: got ${ttl}`);
// scheduleRefresh fires at ttl - 60s — the timer must target the real expiry.
const t = lastTimer();
assert(t && t.ms > 530 * 1000 && t.ms <= 540 * 1000,
`refresh timer targets expiry - 60s: got ${t && t.ms}`);
});
test('check 200: already-expired token falls back to the stored TTL (401 recovery path applies)', async () => {
const s = setup({
initialStorage: {
'vw:access': makeJwt(-10), // already expired
'vw:refresh': 'refresh-old',
'vw:session_id': 'sess-jwt',
'vw:access_ttl': '900000',
},
});
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: {} },
});
await act('check');
assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl');
});
test('check 200: non-JWT stored token falls back to the stored TTL', async () => {
const s = setup(); // default storage carries the non-JWT 'access-old'
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: {} },
});
await act('check');
assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl');
});
test('refresh action: rotated ttl comes from the new token\'s exp claim', async () => {
const s = setup();
s.route('/api/auth/refresh', 200, {
ok: true,
data: {
tokens: { access_token: makeJwt(450), refresh_token: 'r2', session_id: 's2' },
access_ttl: 300, // full TTL — must lose to the exp claim
user: { username: 'alice' },
permissions: { firewall: 'rw' },
},
});
await act('refresh');
const ttl = getModel('auth').data.ttl;
assert(ttl > 440 * 1000 && ttl <= 450 * 1000,
`exp-based ttl (~450s), not access_ttl 300s: got ${ttl}`);
});
test('login action: ttl comes from the issued token\'s exp claim', async () => {
const s = setup();
await modelFetch('auth', {
action: 'login',
payload: {
tokens: { access_token: makeJwt(900), refresh_token: 'r1', session_id: 's1' },
access_ttl: 900,
user: { username: 'alice' },
permissions: { firewall: 'rw' },
},
});
const ttl = getModel('auth').data.ttl;
assert(ttl > 890 * 1000 && ttl <= 900 * 1000,
`exp-based ttl (~900s): got ${ttl}`);
});
/* ── Runner ────────────────────────────────────────────────── */ /* ── Runner ────────────────────────────────────────────────── */
(async () => { (async () => {
+301
View File
@@ -0,0 +1,301 @@
/**
* Tests for hoover/render.js component lifecycle (per-container #comp registry).
*
* Regression: the #comp lifecycle registry and expanded-content cache were
* module-globals, pruned per-container inside normalizeVNodesWithLifecycle().
* Because commitAll() commits #sidebar (no #comp) before #main (the page
* #comp), every sidebar commit unmounted+pruned the page from the global
* registry, so the following #main commit treated the page as newly mounted
* and re-ran load(). For pages whose load() re-mutates reactive state with
* fresh values each run (passkeys.js, users.js), every re-run scheduled
* another commit an infinite unmount/remount/load loop (~100 fetches/s),
* leaving the page stuck on "Loading...".
*
* render.js pulls in vdom.js + component.js DOM-only at commit time, so the
* tests run under plain node with a minimal fake DOM (same pattern as
* test-auth-model.js / test-model-set.js).
*
* Run with `node tests/test-render-lifecycle.js`
* (optional arg 1: hoover root, defaults to ../webui/static/hoover).
*
* NOTE: against buggy (global-registry) code the self-mutation test spins the
* infinite remount loop and saturates the event loop the process hangs
* instead of failing an assertion (mirrors the live symptom). Run under an
* external `timeout` when checking old checkouts:
* timeout 30 node tests/test-render-lifecycle.js <hoover-root>
*/
import { pathToFileURL } from 'node:url';
import path from 'node:path';
const HOOVER_ROOT = process.argv[2]
? pathToFileURL(path.resolve(process.argv[2])).href + '/'
: new URL('../webui/static/hoover/', import.meta.url).href;
/* ── Minimal fake DOM ───────────────────────────────────────── */
class FakeEl {
constructor(tag) {
this.tagName = String(tag || 'div').toUpperCase();
this.nodeType = 1;
this.childNodes = [];
this.parentNode = null;
this.style = { cssText: '' };
this.attributes = {};
this._listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.selected = false;
this.disabled = false;
}
get firstChild() { return this.childNodes[0] || null; }
setAttribute(k, v) { this.attributes[k] = String(v); }
removeAttribute(k) { delete this.attributes[k]; }
appendChild(c) {
if (c.parentNode) c.parentNode.removeChild(c);
c.parentNode = this;
this.childNodes.push(c);
return c;
}
insertBefore(c, ref) {
if (c.parentNode) c.parentNode.removeChild(c);
c.parentNode = this;
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length;
this.childNodes.splice(i === -1 ? this.childNodes.length : i, 0, c);
return c;
}
removeChild(c) {
const i = this.childNodes.indexOf(c);
if (i !== -1) this.childNodes.splice(i, 1);
c.parentNode = null;
return c;
}
replaceChild(nd, od) {
const i = this.childNodes.indexOf(od);
if (i !== -1) this.childNodes[i] = nd;
od.parentNode = null;
nd.parentNode = this;
return od;
}
addEventListener(ev, fn) { (this._listeners[ev] ||= []).push(fn); }
removeEventListener(ev, fn) {
const arr = this._listeners[ev] || [];
const i = arr.indexOf(fn);
if (i !== -1) arr.splice(i, 1);
}
}
class FakeText {
constructor(text) { this.nodeType = 3; this.nodeValue = String(text); this.parentNode = null; }
}
globalThis.document = {
createElement: (tag) => new FakeEl(tag),
createTextNode: (t) => new FakeText(t),
};
globalThis.window = { addEventListener: () => {} };
/* ── Imports (dynamic: hoover root is injectable) ───────────── */
const { reactive } = await import(HOOVER_ROOT + 'reactivity.js');
const { h } = await import(HOOVER_ROOT + 'vdom.js');
const { render } = await import(HOOVER_ROOT + 'render.js');
const { definePage, hComp } = await import(HOOVER_ROOT + 'component.js');
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${a}, want ${b}`);
}
const flush = () => new Promise(r => setTimeout(r, 20));
/**
* Build a page whose load() mutates reactive state (like passkeys.js
* loadCredentials: refreshing=true before the fetch, credentials=<new array>
* and refreshing=false after fresh values on every run).
*/
function makePage(label, counters, title) {
const state = reactive({ loading: true, done: 0 });
return {
state,
page: definePage({
title: title || undefined,
init: () => state,
async load(s) {
counters.loads++;
counters.loadKeys.push(label);
s.done = (s.done || 0) + 1; // fresh value every run → schedules a commit
s.loading = false;
},
onUnmount: () => { counters.unmounts++; counters.unmountKeys.push(label); },
render: () => h('div', { class: 'card' }, `${label}-body`),
}),
};
}
const freshCounters = () => ({ loads: 0, unmounts: 0, loadKeys: [], unmountKeys: [] });
test('initial mount runs load() exactly once', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
await flush();
assertEq(c.loads, 1, 'load ran once');
assertEq(c.unmounts, 0, 'no unmounts');
});
test('external reactive update does NOT re-mount the page', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
await flush();
assertEq(c.loads, 1, 'baseline');
// Simulate a WS tick / toast / any reactive mutation outside the page.
const external = reactive({ n: 1 });
for (let i = 0; i < 3; i++) {
external.n += 1;
await flush();
}
assertEq(c.loads, 1, 'load still ran exactly once after 3 external updates');
assertEq(c.unmounts, 0, 'page was never unmounted');
});
test('page load() self-mutations do not re-trigger load (no infinite loop)', async () => {
const c = freshCounters();
const { page } = makePage('A', c);
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(page, '/page-a'));
// load() mutates reactive state on every run — give the (buggy) loop time
// to spin. With the per-container registry it must stay at exactly one run.
await flush();
await flush();
await flush();
assertEq(c.loads, 1, 'no remount loop driven by load\'s own state mutations');
assertEq(c.unmounts, 0, 'no spurious unmounts');
});
test('navigation unmounts the old page once and mounts the new page once', async () => {
const c = freshCounters();
const a = makePage('A', c);
const b = makePage('B', c);
const nav = reactive({ path: '/page-a' });
const sidebar = new FakeEl('div');
const main = new FakeEl('div');
render(sidebar, () => h('div', { class: 'sidebar' }, 'nav'));
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(c.loads, 1, 'A mounted');
nav.path = '/page-b';
await flush();
assertEq(c.loads, 2, 'B mounted once');
assertEq(c.unmounts, 1, 'A unmounted once');
assertEq(c.unmountKeys[0], 'A', 'A was the unmounted page');
// navigate back — A mounts again with preserved state (load re-runs by design)
nav.path = '/page-a';
await flush();
assertEq(c.loads, 3, 'A re-mounted after navigation back');
assertEq(c.unmounts, 2, 'B unmounted');
assertEq(a.state.done, 2, 'A state preserved across unmount (2 loads total)');
});
test('two #comp containers: updates in one root do not disturb the other', async () => {
const c = freshCounters();
const left = makePage('L', c);
const right = makePage('R', c);
const l = new FakeEl('div');
const r = new FakeEl('div');
render(l, () => hComp(left.page, '/left'));
render(r, () => hComp(right.page, '/right'));
await flush();
assertEq(c.loads, 2, 'both pages mounted');
const external = reactive({ n: 1 });
for (let i = 0; i < 3; i++) { external.n += 1; await flush(); }
assertEq(c.loads, 2, 'neither page re-mounted');
assertEq(c.unmounts, 0, 'neither page unmounted');
});
/* ── Tab title (definePage `title`) ─────────────────────────── */
test('mounting a titled page sets document.title', async () => {
const c = freshCounters();
const { page } = makePage('T', c, 'Titled - Vacuum Wall');
const main = new FakeEl('div');
document.title = 'base';
render(main, () => hComp(page, '/titled'));
await flush();
assertEq(document.title, 'Titled - Vacuum Wall', 'title applied on mount');
});
test('a page without a title leaves document.title untouched', async () => {
const c = freshCounters();
const { page } = makePage('U', c);
const main = new FakeEl('div');
document.title = 'unchanged';
render(main, () => hComp(page, '/untitled'));
await flush();
assertEq(document.title, 'unchanged', 'no title → document.title untouched');
});
test('navigation updates document.title; remount re-applies idempotently', async () => {
const c = freshCounters();
const a = makePage('A', c, 'Alpha - Vacuum Wall');
const b = makePage('B', c, 'Beta - Vacuum Wall');
const nav = reactive({ path: '/page-a' });
const main = new FakeEl('div');
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title on first mount');
nav.path = '/page-b';
await flush();
assertEq(document.title, 'Beta - Vacuum Wall', 'B title after navigation');
nav.path = '/page-a';
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title re-applied on remount');
});
test('mounting an untitled page does not reset a previously set title', async () => {
const c = freshCounters();
const a = makePage('A', c, 'Alpha - Vacuum Wall');
const b = makePage('B', c);
const nav = reactive({ path: '/page-a' });
const main = new FakeEl('div');
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'baseline');
nav.path = '/page-b';
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'untitled mount keeps prior title');
});
/* ── Runner ─────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+32
View File
@@ -277,3 +277,35 @@ class TestHasAutoRenew:
with patch.object(acme, "_ACME_HOME", acme_dir): with patch.object(acme, "_ACME_HOME", acme_dir):
result = acme._has_auto_renew("nonexistent.com") result = acme._has_auto_renew("nonexistent.com")
assert result is False assert result is False
class TestSummarizeAcmeOutput:
def test_last_two_lines(self):
out = (
"[2026-09-04] line one\n[2026-09-04] retry failed\n[2026-09-04] giving up\n"
)
assert acme._summarize_acme_output(out) == "retry failed; giving up"
def test_strips_timestamps_and_log_pointer(self):
out = "[ts] work\nPlease check log file /x/acme.sh.log\n[ts] done\n"
assert acme._summarize_acme_output(out) == "work; done"
def test_empty_returns_placeholder(self):
assert acme._summarize_acme_output("") == "(no output)"
def test_preserves_permission_denied_outside_tail(self):
out = (
"[ts] starting\n"
"[ts] /data/acme/account.conf: Permission denied\n"
"[ts] step three\n"
"[ts] step four\n"
)
summary = acme._summarize_acme_output(out)
# The permission line is not among the final two, but the
# actionable-error matcher (daemon/collectors/acme.py) keys off it.
assert "account.conf: Permission denied" in summary
assert summary.count("; ") == 2 # capped at three lines
def test_permission_denied_in_tail_not_duplicated(self):
out = "[ts] ok\n[ts] account.conf: Permission denied\n"
assert acme._summarize_acme_output(out) == "ok; account.conf: Permission denied"
+20
View File
@@ -349,6 +349,26 @@ class TestGenerateServerConf:
out = nginx.generate_server_conf(cfg) out = nginx.generate_server_conf(cfg)
assert "location /static/" not in out assert "location /static/" not in out
def test_management_static_location_on_subpath(self, temp_data_dir):
# The SPA references /static/... at the domain root regardless of the
# management backend path, so the block is emitted for any
# is_management path, not only '/'.
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/app": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "location /static/ {" in out
static_root = str(nginx.PROJECT_DIR / "webui" / "static")
assert f"alias {static_root}/;" in out
def test_websocket_path(self, temp_data_dir): def test_websocket_path(self, temp_data_dir):
cfg = { cfg = {
"domain": "mgmt.example.com", "domain": "mgmt.example.com",
+166
View File
@@ -1,6 +1,7 @@
"""Tests for lib/state.py — state store and collect functions.""" """Tests for lib/state.py — state store and collect functions."""
import json import json
import os
from unittest.mock import patch from unittest.mock import patch
import daemon.collectors.acme import daemon.collectors.acme
@@ -267,6 +268,10 @@ class TestAcmeCollectNonFatal:
patch.object( patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c" daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
), ),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home"),
patch( patch(
"lib.acme.list_certs", "lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"), side_effect=RuntimeError("acme.sh failed with exit code 2"),
@@ -289,6 +294,10 @@ class TestAcmeCollectNonFatal:
patch.object( patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c" daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
), ),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home"),
patch("lib.acme.list_certs", return_value=[]), patch("lib.acme.list_certs", return_value=[]),
patch.object( patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
@@ -298,6 +307,163 @@ class TestAcmeCollectNonFatal:
assert result["status"] == {"error": None} assert result["status"] == {"error": None}
def test_self_heal_normalizes_before_list(self):
from daemon.collectors.acme import _collect_acme
order: list[str] = []
def _norm():
order.append("normalize")
def _list():
order.append("list")
return [{"domain": "example.com"}]
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
),
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
patch("lib.acme.list_certs", side_effect=_list),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
# The poll must normalize ACME_HOME perms before listing when the
# probe detects a lost group-read bit, so a mid-lifetime ownership
# flip self-heals without a restart.
assert order == ["normalize", "list"]
assert result["certs"] == [{"domain": "example.com"}]
assert result["status"] == {"error": None}
def test_no_normalize_when_probe_clean(self):
from daemon.collectors.acme import _collect_acme
order: list[str] = []
def _norm():
order.append("normalize")
def _list():
order.append("list")
return []
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"daemon.collectors.acme._acme_home_needs_normalize", return_value=False
),
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
patch("lib.acme.list_certs", side_effect=_list),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
# Steady state: the probe sees group-read bits intact, so the poll
# must not pay for a sudo normalize.
assert order == ["list"]
assert result["certs"] == []
assert result["status"] == {"error": None}
class TestAcmeHomeProbe:
"""_acme_home_needs_normalize probes the group-read bit without sudo."""
def test_flags_file_without_group_read(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
(tmp_path / "account.conf").write_text("x")
os.chmod(tmp_path / "account.conf", 0o600)
assert _acme_home_needs_normalize() is True
def test_clean_when_group_read_set(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
(tmp_path / "account.conf").write_text("x")
os.chmod(tmp_path / "account.conf", 0o640)
assert _acme_home_needs_normalize() is False
def test_clean_on_empty_home(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path))
assert _acme_home_needs_normalize() is False
def test_clean_on_missing_home(self, tmp_path, monkeypatch):
from daemon.collectors.acme import _acme_home_needs_normalize
monkeypatch.setenv("ACME_HOME", str(tmp_path / "does-not-exist"))
assert _acme_home_needs_normalize() is False
def test_permission_error_is_actionable(self):
from daemon.collectors.acme import _collect_acme
msg = "acme.sh failed with exit code 2: .../account.conf: Permission denied"
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("daemon.handlers.acme.normalize_acme_home"),
patch("lib.acme.list_certs", side_effect=RuntimeError(msg)),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
assert result["status"]["error"] is not None
assert "sudo chown" in result["status"]["error"]
class TestParseAccountConf:
"""_parse_account_conf reads acme.sh v3's account.conf (no leading dot)."""
def test_reads_no_dot_account_conf(self, tmp_path):
from daemon.collectors.acme import _parse_account_conf
(tmp_path / "account.conf").write_text(
"ACME_LEEMAIL='me@example.com'\nACME_MCA='zerossl'\nACME_CERTKEYSIZE=256\n"
)
acct = _parse_account_conf(acme_home=tmp_path)
assert acct["registered"] is True
assert acct["email"] == "me@example.com"
assert acct["ca"] == "ZeroSSL"
assert acct["key_length"] == 256
def test_prefers_no_dot_over_legacy_dot(self, tmp_path):
from daemon.collectors.acme import _parse_account_conf
(tmp_path / "account.conf").write_text(
"ACME_LEEMAIL='new@example.com'\nACME_MCA='letsencrypt'\n"
)
(tmp_path / ".account.conf").write_text(
"ACME_LEEMAIL='old@example.com'\nACME_MCA='zerossl'\n"
)
acct = _parse_account_conf(acme_home=tmp_path)
assert acct["email"] == "new@example.com"
def test_falls_back_to_legacy_dot(self, tmp_path):
from daemon.collectors.acme import _parse_account_conf
(tmp_path / ".account.conf").write_text(
"ACME_LEEMAIL='legacy@example.com'\nACME_MCA='zerossl'\n"
)
acct = _parse_account_conf(acme_home=tmp_path)
assert acct["registered"] is True
assert acct["email"] == "legacy@example.com"
assert acct["ca"] == "ZeroSSL"
class TestStateVersions: class TestStateVersions:
def test_version_starts_at_zero(self): def test_version_starts_at_zero(self):
+3 -1
View File
@@ -2,7 +2,9 @@
server.py - Vacuum Wall management WebUI entry point. server.py - Vacuum Wall management WebUI entry point.
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port. for the management domain; authentication is enforced at this layer
(JWT ``Authorization`` + ``X-Session-Id`` middleware), never via nginx
basic auth.
""" """
import contextlib import contextlib
+39 -4
View File
@@ -73,14 +73,17 @@ export function createAuthModel() {
const json = await r.json(); const json = await r.json();
if (!json.ok || !json.data?.user) return null; if (!json.ok || !json.data?.user) return null;
// Server returns ONLY { user, permissions } — merge verified identity // Server returns ONLY { user, permissions } — merge verified identity
// onto the stored token state. // onto the stored token state. TTL is the token's REMAINING
// lifetime (exp claim), not the full issued TTL — the in-memory
// timer must fire before the actual expiry even when the session
// was restored mid-life (page reload/restore).
return { return {
token: stored.access, token: stored.access,
refresh: stored.refresh, refresh: stored.refresh,
session_id: stored.session_id, session_id: stored.session_id,
user: json.data.user, user: json.data.user,
permissions: json.data.permissions, permissions: json.data.permissions,
ttl: stored.ttl || 900 * 1000, ttl: tokenRemainingTtlMs(stored.access, stored.ttl || 900 * 1000),
}; };
} }
@@ -99,7 +102,10 @@ export function createAuthModel() {
session_id: payload.tokens.session_id, session_id: payload.tokens.session_id,
user: payload.user, user: payload.user,
permissions: payload.permissions, permissions: payload.permissions,
ttl: (payload.access_ttl || 900) * 1000, ttl: tokenRemainingTtlMs(
payload.tokens.access_token,
(payload.access_ttl || 900) * 1000
),
}; };
} }
@@ -185,10 +191,39 @@ async function _doRefresh() {
session_id: t.session_id, session_id: t.session_id,
user: json.data.user ?? prev?.user, user: json.data.user ?? prev?.user,
permissions: json.data.permissions ?? prev?.permissions, permissions: json.data.permissions ?? prev?.permissions,
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000), ttl: tokenRemainingTtlMs(
t.access_token,
json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000)
),
}; };
} }
/**
* Remaining lifetime (ms) of an access token from its unverified `exp` claim.
* The payload is decoded WITHOUT signature verification this mirrors the
* server's own unverified-payload extraction (lib/auth.py) and is used only
* to schedule the refresh timer, never to trust the claim. Returns the
* fallback when the token is malformed, undecodable, or already expired.
* @param {string} token - JWT access token
* @param {number} fallbackMs - TTL in ms when the exp claim is unusable
* @returns {number} remaining ms (> 0) or fallbackMs
*/
function tokenRemainingTtlMs(token, fallbackMs) {
try {
const payloadB64 = String(token).split('.')[1];
if (!payloadB64) return fallbackMs;
const padded = payloadB64 + '===='.slice(0, (4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
if (payload && typeof payload.exp === 'number') {
const remaining = payload.exp * 1000 - Date.now();
if (remaining > 0) return remaining;
}
} catch {
/* malformed token — fall back to the configured TTL */
}
return fallbackMs;
}
/** /**
* Read stored token state from sessionStorage. * Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}} * @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
+6 -3
View File
@@ -17,7 +17,6 @@
import { reactive } from './reactivity.js'; import { reactive } from './reactivity.js';
import { h } from './vdom.js'; import { h } from './vdom.js';
import { _compExpandedCache } from './render.js';
/** Registry of mounted components: key → { state } */ /** Registry of mounted components: key → { state } */
const _mounted = new Map(); const _mounted = new Map();
@@ -26,6 +25,7 @@ const _mounted = new Map();
* Define a page component. * Define a page component.
* *
* @param {object} def Page definition * @param {object} def Page definition
* @param {string} [def.title] Full browser tab title; applied to document.title on mount
* @param {function} def.init Return initial state object * @param {function} def.init Return initial state object
* @param {function} [def.load] Optional one-time setup called on mount * @param {function} [def.load] Optional one-time setup called on mount
* @param {function} def.render Render function that returns vnodes * @param {function} def.render Render function that returns vnodes
@@ -53,6 +53,7 @@ export function definePage(def) {
}, },
load: def.load || null, load: def.load || null,
onUnmount: def.onUnmount || null, onUnmount: def.onUnmount || null,
title: def.title || null,
}; };
return renderer; return renderer;
@@ -66,6 +67,8 @@ export function mountComponent(key, renderer) {
const pd = renderer._pageDef; const pd = renderer._pageDef;
if (!pd) return; if (!pd) return;
if (pd.title) document.title = pd.title;
let entry = _mounted.get(key); let entry = _mounted.get(key);
if (entry) { if (entry) {
@@ -90,7 +93,7 @@ export function mountComponent(key, renderer) {
* Unmount a page component. Called by the render engine when a #comp vnode * Unmount a page component. Called by the render engine when a #comp vnode
* is removed from the tree. * is removed from the tree.
*/ */
export function unmountComponent(key, renderer) { export function unmountComponent(key, renderer, compCache) {
const entry = _mounted.get(key); const entry = _mounted.get(key);
if (!entry) return; if (!entry) return;
@@ -103,7 +106,7 @@ export function unmountComponent(key, renderer) {
try { pd.onUnmount(entry.state); } catch (_) {} try { pd.onUnmount(entry.state); } catch (_) {}
} }
_compExpandedCache.delete(key); if (compCache) compCache.delete(key);
_mounted.delete(key); _mounted.delete(key);
} }
+46 -22
View File
@@ -18,11 +18,31 @@ export const _renderSlots = new Map();
/** Container → render function */ /** Container → render function */
export const _renderFns = new Map(); export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */ /** Container → (component key → last normalized #comp output, for _vnodeDom preservation) */
export const _compExpandedCache = new Map(); const _compExpandedCaches = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */ /** Container (component key renderer function). Per-container: a commit of one
const _compRegistry = new Map(); * render root must not unmount/prune components owned by another root (e.g. #main's
* page when #sidebar commits). Survives normalization that expands #comp. */
const _compRegistries = new Map();
function _registryFor(container) {
let m = _compRegistries.get(container);
if (!m) {
m = new Map();
_compRegistries.set(container, m);
}
return m;
}
function _expandedCacheFor(container) {
let m = _compExpandedCaches.get(container);
if (!m) {
m = new Map();
_compExpandedCaches.set(container, m);
}
return m;
}
/** /**
* Set up lifecycle callback hooks from vdom.js. * Set up lifecycle callback hooks from vdom.js.
@@ -69,8 +89,9 @@ function commit(container) {
if (typeof result === 'function') result = result(); if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container); const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle // Normalize: expand #comp vnodes and track lifecycle (this container's own
const vnodes = normalizeVNodesWithLifecycle(result, prev); // registry — other roots' commits must not touch our component keys).
const vnodes = normalizeVNodesWithLifecycle(result, prev, container);
if (!prev) { if (!prev) {
for (const v of vnodes) { for (const v of vnodes) {
@@ -89,16 +110,18 @@ function commit(container) {
* Normalize render output: filter nulls, expand #comp vnodes, * Normalize render output: filter nulls, expand #comp vnodes,
* and manage component lifecycle based on key changes. * and manage component lifecycle based on key changes.
*/ */
function normalizeVNodesWithLifecycle(result, prevVnodes) { function normalizeVNodesWithLifecycle(result, prevVnodes, container) {
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer })); const registry = _registryFor(container);
const compCache = _expandedCacheFor(container);
const oldEntries = [...registry.entries()].map(([key, renderer]) => ({ key, renderer }));
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e])); const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = []; const newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries); const normalized = normalizeRecursive(result, oldKeyMap, newEntries, null, compCache);
for (const entry of oldEntries) { for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) { if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer); unmountComponent(entry.key, entry.renderer, compCache);
} }
} }
for (const entry of newEntries) { for (const entry of newEntries) {
@@ -107,14 +130,15 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
} }
} }
// Sync registry with current render (prevVnodes are normalized and lack #comp tags, // Sync this container's registry with the current render (prevVnodes are
// so collectCompEntries always returns [] after the first render) // normalized and lack #comp tags, so collectCompEntries always returns []
// after the first render)
const newKeySet = new Set(newEntries.map(e => e.key)); const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) { for (const [key] of registry) {
if (!newKeySet.has(key)) _compRegistry.delete(key); if (!newKeySet.has(key)) registry.delete(key);
} }
for (const entry of newEntries) { for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer); registry.set(entry.key, entry.renderer);
} }
return normalized; return normalized;
@@ -127,13 +151,13 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
* When prevCh is provided, preserves _vnodeDom entries so that diff * When prevCh is provided, preserves _vnodeDom entries so that diff
* can locate existing DOM after normalization creates new vnode objects. * can locate existing DOM after normalization creates new vnode objects.
*/ */
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) { function normalizeRecursive(result, oldKeyMap, newEntries, prevCh, compCache) {
if (result == null) return []; if (result == null) return [];
if (Array.isArray(result)) { if (Array.isArray(result)) {
const flat = []; const flat = [];
let idx = 0; let idx = 0;
for (const item of result) { for (const item of result) {
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx])); flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx], compCache));
idx++; idx++;
} }
return flat; return flat;
@@ -152,19 +176,19 @@ function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
} }
if (renderer && typeof renderer === 'function') { if (renderer && typeof renderer === 'function') {
const content = renderer(); const content = renderer();
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null; const prevExpanded = key !== undefined && compCache ? compCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded); const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded, compCache);
if (key !== undefined) _compExpandedCache.set(key, result); if (key !== undefined && compCache) compCache.set(key, result);
return result; return result;
} }
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh); return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh, compCache);
} }
const rawChildren = vnode.ch || []; const rawChildren = vnode.ch || [];
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null; const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
const children = []; const children = [];
for (let i = 0; i < rawChildren.length; i++) { for (let i = 0; i < rawChildren.length; i++) {
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]); const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i], compCache);
children.push(...normalized); children.push(...normalized);
} }
+1
View File
@@ -256,6 +256,7 @@ export function openBackendModal(state, backend) {
// Page // Page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default definePage({ export default definePage({
title: 'Backends - Vacuum Wall',
init() { init() {
return { return {
backends: getModel('backends'), backends: getModel('backends'),
+1
View File
@@ -326,6 +326,7 @@ async function pollCertIssue(rid) {
} }
export default definePage({ export default definePage({
title: 'Certificates - Vacuum Wall',
init() { init() {
return { return {
acme: getModel('acme'), acme: getModel('acme'),
+1
View File
@@ -41,6 +41,7 @@ function diffLine(d) {
} }
export default definePage({ export default definePage({
title: 'Dashboard - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -107,6 +107,7 @@ const addDns = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'DHCP & DNS - Vacuum Wall',
init() { init() {
return { return {
dnsmasq: getModel('dnsmasq'), dnsmasq: getModel('dnsmasq'),
+1
View File
@@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Interfaces - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+2 -1
View File
@@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => {
}; };
const Page = definePage({ const Page = definePage({
title: 'Login - Vacuum Wall',
init() { init() {
document.title = 'Login — Vacuum Wall'; return {};
}, },
load() { load() {
+1
View File
@@ -9,6 +9,7 @@ const logTabs = [
]; ];
export default definePage({ export default definePage({
title: 'Logs - Vacuum Wall',
init() { init() {
return { return {
logs: getModel('logs'), logs: getModel('logs'),
+1
View File
@@ -24,6 +24,7 @@ const addFwd = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'NAT - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -1,6 +1,7 @@
import { html, PageHeader, definePage } from '/static/hoover/index.js'; import { html, PageHeader, definePage } from '/static/hoover/index.js';
export default definePage({ export default definePage({
title: '404 - Vacuum Wall',
init() { init() {
return { path: location.hash.slice(1) || '' }; return { path: location.hash.slice(1) || '' };
}, },
+1 -1
View File
@@ -257,8 +257,8 @@ function CredentialsPage() {
} }
const Page = definePage({ const Page = definePage({
title: 'Passkeys - Vacuum Wall',
init() { init() {
document.title = 'Passkeys — Vacuum Wall';
return state; return state;
}, },
+1
View File
@@ -250,6 +250,7 @@ function backendSection(section, state, set) {
} }
export default definePage({ export default definePage({
title: 'Proxy - Vacuum Wall',
init() { init() {
return { return {
nginx: getModel('nginx'), nginx: getModel('nginx'),
+1
View File
@@ -15,6 +15,7 @@ const addRule = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Rules - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -252,6 +252,7 @@ function UsersPage() {
} }
export default definePage({ export default definePage({
title: 'Users - Vacuum Wall',
init() { init() {
return state; return state;
}, },
+1
View File
@@ -402,6 +402,7 @@ function renderAccessClasses(config, status) {
/* ── Main Page ───────────────────────────────────────────────── */ /* ── Main Page ───────────────────────────────────────────────── */
export default definePage({ export default definePage({
title: 'WireGuard - Vacuum Wall',
init() { init() {
return { return {
wireguard: getModel('wireguard'), wireguard: getModel('wireguard'),
+1
View File
@@ -24,6 +24,7 @@ const addZone = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Zones - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),