Compare commits

..

11 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
mteehan fc478a016e nginx: serve mgmt /static/ assets from disk (no Flask round-trip)
Generated management-domain server blocks now include a
location /static/ aliasing webui/static/ with Cache-Control: no-cache
(ETag revalidation -> 304), nosniff, and a restrictive CSP, so SPA
asset requests no longer reach Flask. Flask's static route remains
the dev-mode fallback.

- lib/nginx.py + daemon/handlers/nginx.py: static_root render context
  (the handler renders the template directly, so both paths need it)
- template: alias uses a trailing slash (nginx concatenates the
  location remainder onto the alias value)
- install.sh: a+rX on webui/static plus execute-up-the-parent-chain
  so the nginx worker (www-data) can traverse repo-in-$HOME installs
- tests: mgmt static location assertions (positive + non-mgmt negative)
- docs: AGENTS.md, architecture.md, security.md static-serving notes
2026-09-03 14:58:57 +00:00
mteehan d78b90db00 ui: toast per-type durations, Details modal for long errors, concise acme.sh failure summary 2026-09-03 03:29:50 +00:00
mteehan 7e6fd71bdc test: acme --log flag asserts explicit log file path 2026-09-03 03:29:38 +00:00
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00
mteehan 89b64960f3 ui: amber pending-edit markers for unapplied config changes
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards
edited (saved to config) but not yet applied, consuming the pending
state the daemon already streams — status.pending_diff for hash
subsystems, firewall pending zone+type for firewalld. Visual language
is amber (.config-dirty + PendingDot), distinct from the red
.pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard
peers) on their container table. Wired into the backends, dhcp,
interfaces, nat, proxy, rules, wireguard, and zones pages; Card and
Table gain cls/title props. Covered by 27 node tests
(tests/test-dirty.js).
2026-09-01 20:17:15 +00:00
mteehan 75b86fd60d fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
  broke the two-user model: a tree left owner-only by one user made
  every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
  files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
  is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
  transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
  validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
  status.error (AcmeState.status) so the certs page can surface it

firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport

status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm

system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes

nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)

install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership

daemon/server: loop.get_exception_handler() (aiohttp API fix)

tests: 888 pytest + 24 node passing; ruff clean
2026-09-01 02:35:04 +00:00
99 changed files with 6697 additions and 4470 deletions
+26 -4
View File
@@ -39,10 +39,15 @@ Basic auth (`.htpasswd`) renders **only** for proxy domains whose
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
- `daemon/handlers/*.py` — Privileged operation handlers. All mutating `sudo` calls live here.
- `daemon/collectors/` — Per-subsystem state collectors (7 modules: firewall, dnsmasq, nginx, acme, wireguard, networkd, system). Read-only `sudo` queries that populate `lib.state`. Imported for their registration side-effect; `daemon/server.py` imports the package before the first `populate()`.
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`, plus the apply-bookkeeping helpers `stamp_applied()` / `strip_apply_meta()` / `compute_pending()` / `deep_diff()` / `revert_to_applied()` (the `_last_applied_hash` / `_last_applied_config` keys every config-backed subsystem uses for pending-change detection and cancel-all).
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
- `lib/sync.py` — Cross-subsystem sync event bus (in-process pub/sub); handlers emit events on mutation and subscribers refresh affected subsystems.
- `lib/system_import.py` — Startup system-config import/reconcile: parses native config sources and merges them into the declarative JSON on daemon start.
- `lib/bootstrap.py` — Daemon-startup filesystem bootstrap, run **after** `system_import.import_all()` (which must see absent config files to adopt live state on first start) and before the first state collection: creates the runtime `config/`+`data/` directories and persists the one-shot nginx legacy-format migration. Never creates config files (reads stay pure; files appear on first `save_config`).
- `lib/schema.py` — TypedDict state schemas for the per-subsystem state payloads.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
@@ -63,7 +68,7 @@ Conventions:
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
- No build step — ES modules served raw. Cache controlled via HTTP headers.
- No build step — ES modules served raw. Cache controlled via HTTP headers. For the management domain, nginx serves `/static/` directly from `webui/static/` (generated `location /static/` alias with `no-cache` + ETag revalidation); Flask's static route is the dev-mode fallback.
### Daemon Endpoints
@@ -108,6 +113,7 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
| `webui/api/auth` | `/api/auth/` | `daemon/handlers/auth` | `lib.auth` / `lib.auth_users` |
## Privileged Operations
@@ -118,6 +124,21 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
**Config reads are pure.** Every `lib/<subsystem>.get_config()` is a side-effect-free
read (returns in-memory defaults when the file is missing; nginx applies its
legacy-format migration in memory). State collectors therefore never write to
disk — filesystem setup (runtime dirs, one-shot nginx migration) happens once at
daemon startup in `lib/bootstrap.py` (after `system_import.import_all()`).
**Firewall interface-coverage invariant.** Every network-managed interface
(`lo`/`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 = empty list; no hands-off
zones). Enforced at save time (`POST`/`PATCH /firewall/config` → 400) and apply
time (`POST /firewall/config/apply` → 409, `force: true` overrides) via the pure
`lib.firewall.validate_coverage()`. Live drift is advisory only
(`uncovered_interfaces` state field). See `docs/config.md`.
## API Response Contract
- Success: `{"ok": true, "data": <value>}``_ok(data)` (Flask) or `ok(data)` (aiohttp)
@@ -140,7 +161,7 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
**Tests:** pytest in `tests/` (28 Python + 9 JS test files). All subprocess calls are mocked — no system services required.
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
@@ -160,4 +181,5 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/hoover.md` | Custom frontend framework API reference |
| `docs/state-model.md` | Per-subsystem state schema, the `versions`/`tick` two-layer diff, and the pending-changes model |
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
-289
View File
@@ -1,289 +0,0 @@
# Hardening Plan — Firewall Zone Handling (post DHCP-incident)
Status: ready to implement (line refs re-verified against code 2026-08-28; review
corrections applied 2026-08-28; full cross-file re-verification with minor clarifications
applied 2026-08-28).
Background: LAN clients stopped receiving DHCP leases because the `internal` zone lost its
`eth1` assignment (in both live firewalld and `config/firewall/config.json` — incident-time
state; the appliance was recovered before this plan was written, so the repo's
`config.json` now shows `internal: eth1` again, per Sequencing item 4). With no zone
covering `eth1`, all inbound traffic hit the default `reject` policy — DHCP (and everything
else) from the LAN was dropped before reaching a healthy dnsmasq. Nothing flagged the
uncovered-interface condition; every apply silently reinforced it. Follow-ups below close
that blind spot and the related sharp edges. `/etc/sudoers.d/wall` stays as-is (dev privs,
user-acknowledged).
Decisions (confirmed):
- Target drift: **Option A** — absent `target` = unmanaged (not diffed, not touched by apply).
The absent key is the *single* canonical form for "unmanaged". A zone whose `target`
normalizes to `default` (e.g. the legacy explicit `"DEFAULT"`) is treated as unmanaged in
the pending diff as well — it mirrors `_config_apply`, which only ever sets
`ACCEPT/DROP/REJECT`. No backward compatibility is needed.
- Coverage guard: **ConflictError + `force`** in `_config_apply`; UI confirm in the
interfaces picker when an interface's last zone is dropped (zone *deletion* is out of
scope — `delete_zone` stays unguarded).
- Guard scope: **explicit `lo`/`wg*` filter** — guarded ifaces = network-config keys minus
`lo` and `wg*` prefixes (regardless of config contents); the coverage union also counts
live interfaces of zones absent from config (apply never touches them).
- Config cleanup is applied via **`POST /api/firewall/config` (full replace) + apply**, not
a raw JSON edit (re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent). `PATCH` cannot be used: `deep_merge` (lib/common.py:244-256)
has no key-removal path, so the current `patch_config` endpoint cannot delete the
`target` keys (a `null` value would be written instead).
- Execution: **parallel streams** — WI-1 (A/B/C) + WI-3 are file-disjoint and run concurrently;
WI-2 → WI-4 → WI-5 stay serial (shared files: `_config_apply`, `_compute_pending_changes`,
`tests/test_firewall.py`, `docs/config.md`).
- Pin `"target": "ACCEPT"` for `internal` in config (declared intent = trusted LAN).
Extra latent bugs found during research (folded in):
- `create_zone` (daemon/handlers/firewall.py:655-663) never creates the zone at all: the
`--new-zone` step is missing and it runs `firewall-cmd --set-target=<target>` on a
nonexistent zone. For the default target it would run `--set-target=default`, which the
codebase's defensive guards treat as un-settable (the new-zone branch of
`_config_apply`, lines 188-205, already guards this; `create_zone` does not). The
current firewalld man page (verified via live docs 2026-08-28) lists `default` as an
accepted `--set-target` value for zones; the planned defensive skip of default targets
is still correct under Option A. An appliance-side check of actual behavior is optional
during live verification, not required.
- `_compute_pending_changes` (lib/firewall.py:380-381) skips entire zones with no
`interfaces` key — no field drift is ever reported for such zones.
- The explicit `"target": "DEFAULT"` entries in `config/firewall/config.json` (public,
vpn-full, work) are a one-shot install-time import artifact:
`system_import.import_firewall` (lib/system_import.py:921-932) emits a `target` key for
every imported zone with interfaces, and `_live_target_to_config` maps live `default` to
the config token `"DEFAULT"` (lib/firewall.py:353-361). No other code path writes
`target` into config. Hand/UI-edited zones (e.g. `internal`) omit the key — two
notations for the same meaning. Fixed by WI-2.
---
## WI-1 — Interface-coverage invariant + apply guard (core fix)
Goal: an interface managed by the network subsystem that ends up in *no* zone becomes a loud,
always-visible condition, and bulk-apply cannot silently produce it.
1. `daemon/handlers/firewall.py`, `_config_apply` (lines 138-403):
- Interfaces step (lines 252-279) — new semantics:
- **key absent → hands off**: skip the remove-then-add for that zone (matches the
existing "None = don't change" masquerade semantic at line 281).
- **explicit `[]` → intentional unassign-all** (the UI picker legitimately sends this).
- **Pre-mutation coverage guard**: compute post-apply coverage = union of each zone's
desired interfaces (explicit list if key present, else its *current live* set for
absent-key zones) **plus the live interfaces of zones absent from config** (apply
never touches those; without this the guard false-positives when a live-only zone
still holds an interface). Guarded interfaces = keys of
`lib.network.get_config()["interfaces"]` with `lo` and `wg*` prefixes **explicitly
filtered** (vpn zones are managed by `WgToFirewallSync`; `lo` is normally zoneless —
without the filter a networkd-managed `lo` would make every apply raise). If any
guarded interface would be uncovered → `ConflictError`
naming the interfaces and the consequence (clients lose connectivity/DHCP), overridable
with `body.force = true` (same pattern as the https/ssh lockout at lines 154-169; the
`config_apply` handler at 608-631 already receives `body`). Implementation note: the
guard needs current live interfaces for *all* zones before any mutation — take them
from one `firewall-cmd --get-active-zones` call before the zone loop (same pattern as
`set_zone_interfaces`, daemon/handlers/firewall.py:731-732); do not rely on the
per-zone `--list-all` reads that currently happen inside the mutation loop.
Place the guard alongside the https/ssh lockout check (lines 154-169), i.e. before
the pre-apply backup write — the existing lockout test
(`test_config_apply_blocks_lockout_before_backup`, tests/test_firewall.py:670-677)
pins `mock_backup.assert_not_called()`, and the new guard must respect the same
no-side-effect-on-conflict invariant. Update the `_config_apply` and `config_apply`
docstrings (both currently document only the lockout guard + `force`).
2. `set_zone_interfaces` (lines 706-791): when the new selection leaves an interface in *no*
zone, emit a prominent `logger.warning`. No block — deliberate UI action.
3. `lib/firewall.py`, `_compute_pending_changes` (lines 364-476): remove the blanket
`if not zone_cfg.get("interfaces"): continue` (line 380-381) — only gate the *interfaces*
diff on key presence; report services/target/masquerade/rules/fwd-ports drift for such
zones as today. Implementation detail: the interfaces diff itself must be gated —
`cfg_ifaces = set(zone_cfg.get("interfaces", []))` (line 383) would otherwise diff
`set()` against live for absent-key zones and emit a spurious entry; compute it only
when `"interfaces" in zone_cfg`. Behavior change: zones with `interfaces: []`
(live: `vpn`, `vpn-full`, `work`) will now report field drift on every poll, and
config zones absent from live entirely will diff against an empty zone — the
pending list may be non-empty immediately after merge.
4. `lib/state.py`, `_collect_firewall` (line 449+): compute `uncovered_interfaces`
(network-config ifaces not in any live zone) on every poll, so it is visible even with
zero pending changes. Apply the same `lo`/`wg*` filter as the WI-1.1 guard. A
network-config iface that is absent from live state entirely (down/renamed) counts as
uncovered too — not just zoneless-on-live. Add
`uncovered_interfaces: list[str]` to `schema.FirewallState` (lib/schema.py:87); the
TypedDict is shape-checked against the collector return by
`tests/test_schema_types.py::test_firewall_state` (lines 21-54), so the collector must
always include the key. Update the `FirewallState` block in `docs/state-model.md`
(lines 51-78 — the authoritative Markdown reference per lib/schema.py:3-5). Test
note: the network-config read is a file read, not `run()` — patch
`lib.network.get_config` in `test_firewall_state` (which mocks only `lib.state.run`)
so the value is deterministic. This is the detection that would have caught the
incident within 30s.
5. Surface it:
- `daemon/handlers/status.py` (lines 62-105): advisory coverage warnings in the firewall
section of `/api/status/pending` (not counted in `needs_apply`).
- `webui/static/pages/zones.js`: warning banner from
`state.firewall.data.uncovered_interfaces`.
- `docs/api.md` (line 1965): document the new advisory field in the
`/api/status/pending` firewall section (not counted in `needs_apply`/`total_changes`).
- `docs/api.md`: note that `POST /api/status/apply-all` runs the firewall apply with
`force=false` — a coverage `ConflictError` surfaces in the response `errors` dict
under "Firewall" while the other subsystems proceed (the desired no-silent-apply
behavior).
6. UI guard: the interfaces `MultiSelectModal` in zones.js gets a `confirm` hook (same
pattern as the services lockout at lines 89-99): if the selection would drop an
interface's last zone, warn that clients on that segment lose connectivity and DHCP.
Tests: `tests/test_firewall.py` — absent-key zone keeps live interfaces on apply; explicit
`[]` unassigns; conflict raised when a network iface goes uncovered; `force` bypasses;
guard ignores `lo`/`wg*` even when present in network config; live-only-zone interfaces
count as covered; pending diff now reports services drift on interface-less zones
(absent-key zones emit no spurious interfaces entry). `tests/test_api.py` /
`tests/test_status_pending.py` for the new advisory field. `tests/test_schema_types.py`
for the new `FirewallState` key. Test setup note: the guard reads
`lib.network.get_config()` — a file read, not `run()` — so every non-force `_config_apply`
test must patch `lib.network.get_config` (e.g. return `{"interfaces": {"eth0": {}}}`
matching the mocked live state). Without it, the repo's real
`config/network/config.json` (carries `eth0`+`eth1`) combined with the mocked `run`
(one return string for all calls, so `--get-active-zones` does not cover `eth1`)
raises a spurious `ConflictError`; existing tests affected include
`test_applies_existing_zone` (~560) and `test_stamps_applied_baseline` (~750).
The patch value must be consistent with the mocked live state **per test**:
`{"interfaces": {"eth0": {}}}` only works where the config driving the guard (the
`lib.firewall.get_config` mock) carries an explicit `interfaces` list
(`test_stamps_applied_baseline`, `_STAMP_TEST_CFG` with `"interfaces": ["eth0"]`) —
the explicit list covers eth0 in the post-apply union. In `test_applies_existing_zone`
that mock also carries `"interfaces": ["eth0"]` (the `{"public": {}}` mock is
`_get_config`, used only for the end-of-apply stamp at firewall.py:396), so
`{"interfaces": {"eth0": {}}}` works there too — but the single-string `run` mock
makes `--get-active-zones` parse to garbage covering neither eth0 nor eth1, so the
simplest patch is `{"interfaces": {}}` (or upgrade the `run` mock to a `side_effect`
answering `--get-active-zones` with eth0 covered). Guard ordering: the
https/ssh lockout check (lines 154-169) must run **before** the coverage guard —
`test_config_apply_blocks_lockout_before_backup` (~670) asserts the "https and ssh"
message and mocks neither `run` nor the network config, so a coverage guard evaluated
first would hit the un-mocked `run`/file read and break that test.
## WI-2 — Target-drift semantics (Option A: omit = unmanaged)
Goal: stop the trap where config omits `target` (→ implicit "default"), live says `ACCEPT`,
the pending diff flags it, and apply can never clear it (firewalld cannot set "default" back)
→ permanent fake "pending" + dead apply button.
- `lib/firewall.py`, `_compute_pending_changes`: skip the target diff when the zone config
has no explicit `target` key **or** the value normalizes to `default` (precedent: the
public-masquerade skip at lines 419-424). Defensive — it covers legacy configs still
carrying explicit `"DEFAULT"`. `_config_apply` already leaves default targets alone
(lines 207-219). Explicit `ACCEPT/DROP/REJECT` remains fully managed.
- Fix the source — `lib/system_import.py`, `import_firewall` (lines 921-932): emit the
`"target"` key only when the imported zone's live target normalizes to something other
than `default`. Today the importer emits a faithful snapshot with a `target` key for
every zone, and `_live_target_to_config` maps live `default``"DEFAULT"` (this is the
sole author of the explicit `"target": "DEFAULT"` entries; no other code path writes
`target` into config). Update `tests/test_system_import.py` (assertions at lines
538-574). Keep `_live_target_to_config` itself (lib/firewall.py:353-361; still asserted
at tests/test_firewall.py:148-149).
- `create_zone` (daemon/handlers/firewall.py:632-670): rewrite the body to mirror the full
`_config_apply` new-zone branch (lines 188-205): run `--new-zone` first (currently
missing entirely — the endpoint would create no zone at all), then `--set-target` only
when the target normalizes to something other than `default`, then `_reload()`.
- Config cleanup (on the appliance): remove the legacy `"target": "DEFAULT"` entries from
the `public`, `vpn-full`, and `work` zones (making key-absence the one canonical
"unmanaged" notation), and add `"target": "ACCEPT"` to the `internal` zone — declares the
trusted-LAN intent and makes future apply enforce it and flag any drift. Apply via
`POST /api/firewall/config` (full replace) + apply, **not** a raw JSON edit and **not**
`PATCH` (which cannot delete keys — see the Decisions note above): GET the current
config, drop the three `target` entries, add `"target": "ACCEPT"` to `internal`, POST,
then apply. Apply re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent.
- docs/config.md: document "target omitted (or normalizes to `default`) → live value is
preserved, not diffed, and never re-set by apply".
Tests: pending-diff cases (absent target ⇒ no target entry; explicit `"DEFAULT"` ⇒ no
target entry; explicit `ACCEPT` vs live `default` ⇒ entry); `create_zone` paths
(`--new-zone` always called; `--set-target` only for non-default targets);
`import_firewall` omits `target` for default-target zones while keeping it for
`ACCEPT/DROP/REJECT`.
## WI-3 — Make the sync bus non-destructive (stale DHCP ranges)
Goal: a zone interface change must never delete user data. `FirewallToDhcpSync` currently
hard-deletes ranges the moment an interface loses zone coverage (lib/sync.py:806-822) —
exactly what ate the eth1 pool during the incident's mis-click.
- `lib/sync.py`, `FirewallToDhcpSync.on_firewall_config_saved` (lines 804-822):
- Keep the range in the dnsmasq config.
- Log a warning and emit a `SyncResult.changes` entry: "DHCP range on '<iface>' has no
firewall zone coverage — inactive until a zone covers it".
- Report `dnsmasq` in `affected_subsystems` only when the gateway auto-fill step
(lines 824-855) actually mutated config — the return at sync.py:866 becomes
`["dnsmasq"] if changed else []`.
- Update the class docstring (lines 742-748) and method docstring accordingly.
- Rationale: a range is inert only while the firewall drops the traffic; keeping it makes
zone re-assignment self-heal with zero follow-up.
Tests: `tests/test_sync.py` `TestFirewallToDhcpSync` (lines 763-920): `test_removes_stale_ranges`
becomes `test_flags_uncovered_range_without_deleting` (assert dnsmasq config untouched +
warning present). `test_keeps_global_ranges` also asserts the stale eth2 range is removed
(`len(saved_ranges) == 1`, `dnsmasq` affected, `mock_dm_save.call_args` read
unconditionally) and must be rewritten for the non-destructive semantics (both ranges kept,
no save, no affected subsystems, warning present).
## WI-4 — Make the firewall "backup" real
Goal: `data/firewall/rules.json` stores an empty skeleton before *and* after apply
(daemon/handlers/firewall.py:171-179, 385-393), so the disaster-recovery artifact promised by
docs/architecture.md:134 contains nothing.
- `_config_apply`: save a **pre-apply snapshot only**, before any mutation:
`{timestamp, default_zone, zones: _parse_all_zones_output(firewall-cmd --list-all-zones --permanent),
config: <config.json contents>}``data/firewall/rules.json` via `_save_backup`. The
permanent view is what is reproducible for manual recovery. Note: `_parse_all_zones_output`
must be added to the handler's `lib.firewall` import (daemon/handlers/firewall.py:38-43) —
it is not currently imported there.
- Remove the misleading post-apply skeleton write (lines 385-393); the apply response's
`backup` path field is unchanged. `load_backup` has no live consumers — no API changes.
- Docs: update architecture.md:134/298, config.md:485, overview.md:77 to describe the shape.
Tests: the `_save_backup` patch sites in `tests/test_firewall.py` (~lines 566, 701,
751) carry `return_value="/tmp/rules.json"` and stay valid as-is — no existing test
asserts on its arguments or call count. Optionally add one assertion that the single
pre-apply call receives the snapshot payload (`default_zone`/`zones`/`config` keys).
## WI-5 (optional, low) — Daemon shutdown noise
"Task was destroyed but it is pending" + logging-error tracebacks on daemon SIGTERM (7
occurrences since the Aug 22 restart, still happening on current code).
- `daemon/server.py` shutdown path: stop accepting new connections, give in-flight request
tasks a bounded grace period (`await server.wait_closed()` with timeout) before
`runner.cleanup()`; suppress the asyncio default exception handler during the teardown
window.
Cosmetic. Do last, or defer.
---
## Sequencing & verification
1. One branch; one commit per WI: **1 → 3 → 2 → 4 → 5**. WI-1 + WI-3 together fix the
incident class; WI-2/WI-4 are hygiene; WI-5 optional.
Execution: **Phase 1 in parallel** (file-disjoint streams — A: WI-1 backend,
`daemon/handlers/firewall.py` + `lib/firewall.py` pending-diff; B: WI-1 state surface,
`lib/state.py` + `lib/schema.py` + `daemon/handlers/status.py` + schema/status tests +
`docs/state-model.md` + `docs/api.md`;
C: WI-1 frontend, `webui/static/pages/zones.js`; D: WI-3, `lib/sync.py` +
`tests/test_sync.py`); **Phase 2 serial** — WI-2 → WI-4 → WI-5 (shared files:
`_config_apply`, `_compute_pending_changes`, `tests/test_firewall.py`, `docs/config.md`).
2. Per commit:
- `.venv/bin/ruff check lib/ webui/ daemon/ tests/`
- `.venv/bin/ruff format lib/ webui/ daemon/ tests/`
- `.venv/bin/python -m pytest tests/ -v`
- `node tests/test-*.js` for touched hoover components (zones.js itself has no node test
file — verify by loading the page in the running UI).
3. Live verification on the appliance after merge:
- Confirm `uncovered_interfaces` is empty in firewall state.
- Check the firewall pending list for the expected post-WI-1.3/WI-2 drift entries
(`interfaces: []` zones now report field drift; `internal` target pinned to
`ACCEPT`) and confirm nothing unexpected appears.
- Optional drill: create a throwaway zone and move `eth0` onto it via the API with
`force` omitted (expect ConflictError) and added (expect success + warning), then
restore. Skip if undesired — mocked tests cover the logic.
4. No live-system changes during implementation; DHCP/zone state stays as the operator left
it (internal=eth1, public=eth0, leases confirmed 01:29).
+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
- Python 3.13+, Flask 3.x web UI
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
- acme.sh for ACME certificates (ZeroSSL)
- acme.sh for ACME certificates (CA is config-driven; code default Let's Encrypt)
---
@@ -42,9 +42,9 @@ bash scripts/install.sh
| Flag | Env Var | Required | Description |
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
| `--mgmt-pass` | `MGMT_PASS` | Yes | SQLite DB password for the initial `admin` user (full `rw` on all subsystems) — not an nginx htpasswd |
| `--mgmt-user` | `MGMT_USER` | No | WebUI username (defaults to `admin`) |
| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) |
| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (CA is config-driven; code default Let's Encrypt) |
| `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) |
| `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) |
| `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning |
@@ -99,7 +99,7 @@ All `lib/` modules share `lib.common` utilities (`run`, `run_proc`, `load_json`,
.venv/bin/python -m pytest tests/ -v
```
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 192 tests across 5 test modules.
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 28 Python test files (pytest) + 9 JS test files (jsdom/node harness).
### Documentation MCP Server
@@ -115,17 +115,23 @@ Then run with Claude Code or Opencode to activate it. It automatically checks li
### Architecture
```
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
Client ──→ nginx (TLS; basic auth on basic-authed proxy domains only) ──→ Flask (127.0.0.1:9090)
Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld ──→ handlers ──→ sudo
```
| Blueprint | URL prefix | Backend module |
|---|---|---|
| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
Blueprints are thin proxies — privileged handlers live in `daemon/handlers/*.py`.
| Blueprint | URL prefix | Handler | lib module |
|---|---|---|---|
| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` |
| `webui/api/certs` | `/api/certs/` | `daemon/handlers/acme` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard` | `lib.wireguard` |
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
| `webui/api/auth` | `/api/auth/` | `daemon/handlers/auth` | `lib.auth` / `lib.auth_users` |
See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone model, and shared utility patterns.
@@ -139,3 +145,5 @@ See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone
- [API Reference](docs/api.md) — REST API endpoints
- [Security Model](docs/security.md) — Privilege model and sudo whitelist
- [Configuration](docs/config.md) — Declarative config file formats and locations
- [State Model](docs/state-model.md) — Per-subsystem state schema and real-time push mechanics
- [Frontend (hoover)](docs/hoover.md) — Custom reactive SPA framework API reference
+26
View File
@@ -0,0 +1,26 @@
"""State collectors for vacuum-walld.
Importing this package registers every collector with the ``lib.state``
store (registration side effect). Import it before the first
``populate()``/``poll()`` call.
"""
from daemon.collectors import (
acme,
dnsmasq,
firewall,
networkd,
nginx,
system,
wireguard,
)
__all__ = [
"acme",
"dnsmasq",
"firewall",
"networkd",
"nginx",
"system",
"wireguard",
]
+225
View File
@@ -0,0 +1,225 @@
"""ACME state collector."""
import logging
import os
from pathlib import Path
from stat import S_IRGRP
from typing import Any
from lib import schema
from lib.acme import get_acme_home
from lib.common import load_json
from lib.state import PROJECT_DIR, _now_iso, register_collector
logger = logging.getLogger(__name__)
_CA_NAME_MAP: dict[str, str] = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
def _resolve_ca_name(ca_server: str) -> str:
"""Map a CA server identifier to its human-readable name.
Uses prefix matching sorted by longest prefix first to avoid
shorter prefixes winning (e.g. "letsencrypt" matching before
"letsencrypt.org").
Args:
ca_server: Raw CA server string from acme.sh config.
Returns:
Human-readable name, or unchanged string if no match.
"""
for prefix, name in sorted(
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
):
if ca_server.startswith(prefix):
return name
return ca_server
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh account information and return account status dict.
Checks three sources in order:
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
2. Declarative ``config/acme/config.json`` (saved by the registration
handler with ``email`` and ``ca`` fields)
Args:
acme_home: Optional override for ACME home directory. Falls back
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
Returns:
Dict with ``registered``, ``email``, ``ca``, and
``key_length`` keys. If no account is found, ``registered`` is
``False`` with empty / ``None`` values.
"""
if acme_home is None:
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
default = {
"registered": False,
"email": "",
"ca": "",
"key_length": None,
}
# 1. acme.sh account file. Modern acme.sh (v3.x) writes ``account.conf``;
# older v2.x wrote ``.account.conf``. Check both so the account card
# 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:
text = account_path.read_text()
except OSError:
pass
else:
email = ""
ca_raw = ""
key_length = None
for line in text.splitlines():
if line.startswith("ACME_LEEMAIL="):
email = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_MCA="):
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_CERTKEYSIZE="):
raw_val = line.split("=", 1)[1].strip().strip("'\"")
key_length = int(raw_val) if raw_val.isdigit() else None
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": key_length,
}
# 2. Declarative config (saved by register_account / set_email handlers)
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
# (ca/<server>/account.json) — we can't reliably parse those without
# walking the directory, so fall back to the declarative config
# which the handlers keep in sync.
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
project_root = acme_home.parent.parent # data/acme → data → project root
acme_cfg = project_root / "config" / "acme" / "config.json"
data = load_json(acme_cfg)
email = (data.get("email") or "").strip()
ca_raw = (data.get("ca") or "").strip()
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": None,
}
except (OSError, ValueError):
pass
return default
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
from lib.acme import _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:
"""Collect ACME certificate list and email.
Returns:
Dict containing certificate details and registered email.
"""
email = _get_acme_email()
# Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an
# ownership flip) must not blank the whole dashboard via a cleared
# state store. Collect what we can and surface the failure in
# `status.error` so the poll diff still detects recovery.
cert_error: str | None = None
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
if _acme_home_needs_normalize():
normalize_acme_home()
certs = list_certs()
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)
certs = []
cert_error = _friendly_acme_error(exc)
account = _parse_account_conf()
return {
"certs": certs,
"email": email,
"account": account,
"status": {"error": cert_error},
"timestamp": _now_iso(),
}
register_collector("acme", _collect_acme)
+85
View File
@@ -0,0 +1,85 @@
"""DNSMasq state collector."""
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.dnsmasq import DEFAULT_CFG, get_config
from lib.state import _now_iso, register_collector
def _collect_dnsmasq() -> schema.DnsmasqState:
"""Collect dnsmasq status, config, and leases.
Returns:
Dict containing config, service status, leases, and timestamp.
"""
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
# Load config (lib defaults; fall back to them when the file is broken)
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CFG)
# Service status
service_active = False
try:
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
service_active = proc.stdout.strip() == "active"
except Exception:
pass
# Leases
leases: list[dict[str, Any]] = []
try:
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
leases.append(
{
"expires": ts.isoformat() if ts else "",
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
)
except Exception:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
pending_changes, pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
}
register_collector("dnsmasq", _collect_dnsmasq)
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
+175
View File
@@ -0,0 +1,175 @@
"""Firewall state collector (read-only sudo queries)."""
import contextlib
from typing import Any
from lib import schema
from lib.common import load_json, run, strip_apply_meta
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
get_service_descriptions,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import get_config as _network_get_config
from lib.state import (
PROJECT_DIR,
_now_iso,
register_collector,
register_volatile,
)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a port-forward dict to a compact string representation.
Args:
fp: Port-forward entry containing port and proto keys.
Returns:
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
"""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
return "/".join(parts)
def _collect_firewall() -> schema.FirewallState:
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
iface_map: dict[str, dict[str, Any]] = {}
for line in link_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
iface_state = parts[i + 1]
if p == "mtu" and i + 1 < len(parts):
mtu = int(parts[i + 1])
if p.startswith("link/ether") and i + 1 < len(parts):
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"mac": mac,
"state": iface_state,
"mtu": mtu,
"ips": [],
"ipv6": [],
"zone": None,
}
for line in addr_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1].split("@")[0]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
for entry in iface_map.values():
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
ifaces = list(iface_map.values())
# Collect all zones in a single call (replaces per-zone loop)
zones: dict[str, dict[str, Any]] = {}
try:
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
zones = _parse_all_zones_output(all_zones_raw)
except Exception:
pass
# Load config (strip apply bookkeeping keys, as the other collectors do)
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = strip_apply_meta(load_json(fw_config_path))
# Pending changes
full_state = {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"timestamp": _now_iso(),
}
pending = {}
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
net_cfg: dict[str, Any] = {}
with contextlib.suppress(Exception):
net_cfg = _network_get_config()
covered: set[str] = set()
for zone_ifaces in active.values():
covered.update(zone_ifaces)
for zone in zones.values():
covered.update(zone.get("interfaces", []))
uncovered_interfaces = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg") and name not in covered
]
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
# Parsed from the firewalld service XML definitions; cached per
# process so the 30s poll does not re-read the files.
"service_descriptions": get_service_descriptions(),
"uncovered_interfaces": uncovered_interfaces,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
"pending": pending,
"timestamp": _now_iso(),
}
register_collector("firewall", _collect_firewall)
register_volatile(
"firewall",
frozenset(
{
"interfaces[].ips",
"interfaces[].ipv6",
}
),
)
+67
View File
@@ -0,0 +1,67 @@
"""Networkd state collector."""
from typing import Any
from lib import schema
from lib.common import compute_pending, run, strip_apply_meta
from lib.network import get_config, parse_networkctl_status
from lib.state import _now_iso, register_collector, register_volatile
def _collect_networkd() -> schema.NetworkdState:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output,
config, and pending changes status.
"""
# Load config
try:
net_cfg = get_config()
except Exception:
net_cfg = {}
pending_changes, net_pending_diff = compute_pending(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {
"pending_changes": pending_changes,
"pending_diff": net_pending_diff,
}
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
+66
View File
@@ -0,0 +1,66 @@
"""Nginx state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, strip_apply_meta
from lib.nginx import DEFAULT_CONFIG, SITES_DIR, _resolve_paths, get_config
from lib.state import _now_iso, register_collector
def _collect_nginx() -> schema.NginxState:
"""Collect nginx config and domains list.
Returns:
Dict containing config, domains, and timestamp.
"""
# Load config via lib.nginx — a pure read that applies the legacy-format
# migration in memory (the one-shot on-disk migration runs at daemon
# startup, see lib.bootstrap).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
# Build flattened domains list (one entry per path)
backends = cfg.get("backends", {})
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = _resolve_paths(dom, backends)
if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
"path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
"cert": dom.get("cert"),
}
if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
domains.append(entry)
pending_changes, nginx_pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"domains": domains,
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
register_collector("nginx", _collect_nginx)
+125
View File
@@ -0,0 +1,125 @@
"""System metrics collector."""
from pathlib import Path
from typing import Any
from lib import schema
from lib.state import _now_iso, register_collector, register_volatile
def _parse_meminfo() -> dict[str, Any]:
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
info: dict[str, int] = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
parts = value.strip().split()
val = int(parts[0])
# Convert kB to bytes
if parts and parts[-1] == "kB":
val *= 1024
info[key] = val
except (OSError, ValueError):
return {}
return info
def _collect_system() -> schema.SystemState:
"""Collect system-wide metrics: CPU load, memory, network traffic.
Reads from /proc and /sys — no subprocess needed.
Returns:
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
"""
# CPU load
loads = []
try:
parts = Path("/proc/loadavg").read_text().split()
loads = [float(x) for x in parts[:3]]
except (OSError, ValueError):
loads = [0.0, 0.0, 0.0]
# Memory
meminfo_raw = _parse_meminfo()
mem_total = meminfo_raw.get("MemTotal", 0)
mem_free = meminfo_raw.get("MemFree", 0)
mem_available = meminfo_raw.get("MemAvailable", mem_free)
mem_buffers = meminfo_raw.get("Buffers", 0)
mem_cached = meminfo_raw.get("Cached", 0)
mem_used = mem_total - mem_free - mem_buffers - mem_cached
if mem_used < 0:
mem_used = mem_total - mem_available
# Swap
swap_total = meminfo_raw.get("SwapTotal", 0)
swap_free = meminfo_raw.get("SwapFree", 0)
swap_used = swap_total - swap_free
# Network traffic from /sys/class/net/<iface>/statistics/
traffic: dict[str, dict[str, int]] = {}
try:
net_root = Path("/sys/class/net")
if net_root.is_dir():
for iface_dir in net_root.iterdir():
stats_dir = iface_dir / "statistics"
if not stats_dir.is_dir():
continue
iface_name = iface_dir.name
rx_bytes = 0
tx_bytes = 0
rx_packets = 0
tx_packets = 0
try:
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
except (OSError, ValueError):
continue
traffic[iface_name] = {
"rx_bytes": rx_bytes,
"tx_bytes": tx_bytes,
"rx_packets": rx_packets,
"tx_packets": tx_packets,
}
except OSError:
pass
return {
"load": {
"load1": loads[0],
"load5": loads[1],
"load15": loads[2],
},
"memory": {
"total": mem_total,
"available": mem_available,
"used": mem_used,
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
},
"swap": {
"total": swap_total,
"used": swap_used,
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
},
"traffic": traffic,
"timestamp": _now_iso(),
}
register_collector("system", _collect_system)
register_volatile(
"system",
frozenset(
{
"load",
"memory",
"swap",
"traffic",
}
),
)
+125
View File
@@ -0,0 +1,125 @@
"""WireGuard state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.state import _now_iso, register_collector, register_volatile
from lib.wireguard import DEFAULT_CONFIG, get_config, parse_wg_show_output
def _collect_wireguard() -> schema.WgState:
"""Collect WireGuard config, per-class status, and peers.
Returns:
Dict containing interface config, per-class runtime status,
combined peers, and overall tunnel status.
"""
# Load config via lib.wireguard defaults (which include the built-in
# full/internet access classes).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
pending_changes, pending_diff = compute_pending(cfg)
# Safe config (strip private keys from interface and access classes)
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
safe["access_classes"] = {}
for ck, cv in cfg.get("access_classes", {}).items():
if isinstance(cv, dict):
entry = dict(cv)
entry.pop("private_key", None)
safe["access_classes"][ck] = entry
# Peers list (safe)
peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
peers.append(entry)
# Runtime status — per-class interfaces
status: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
classes = cfg.get("access_classes", {})
any_up = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not isinstance(class_cfg, dict):
continue
ifname = f"wg-{class_key}"
try:
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode != 0:
status["classes"][class_key] = {
"up": False,
"interface": {},
"peers": [],
}
continue
parsed = parse_wg_show_output(res.stdout.strip())
status["classes"][class_key] = {
"up": parsed["up"],
"interface": parsed.get("interface", {}),
"peers": parsed.get("peers", []),
}
if parsed["up"]:
any_up = True
except Exception:
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
# Also collect legacy single-interface status
try:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = parse_wg_show_output(res.stdout.strip())
status["up"] = True
status["interface"] = parsed.get("interface", {})
status["peers"] = parsed.get("peers", [])
any_up = True
except Exception:
pass
if any_up:
status["up"] = True
status["pending_changes"] = pending_changes
# Drop any private-key paths so the pending summary never exposes
# key material.
status["pending_diff"] = [d for d in pending_diff if "private_key" not in d["path"]]
return {
"config": safe,
"status": status,
"peers": peers,
"timestamp": _now_iso(),
}
register_collector("wireguard", _collect_wireguard)
register_volatile(
"wireguard",
frozenset(
{
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
"status.classes[].peers[].transfer_received",
"status.classes[].peers[].transfer_sent",
"status.classes[].peers[].latest_handshake",
}
),
)
+68 -14
View File
@@ -54,6 +54,52 @@ _ACME_ENVIRON = {
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def normalize_acme_home() -> None:
"""Restore group access on the ACME home files around acme.sh runs.
acme.sh hardens its tree on every run (``chmod 700`` on the config
home, ``chmod 600`` on keys and confs, owned by the running user).
The daemon reopens group read/write via the sudoers whitelist so
the shared two-user model keeps the tree readable. Run BEFORE an
acme.sh invocation too: acme.sh dot-sources ``account.conf`` on
startup, so a tree left owner-only by another user's run (e.g. a
manual debug run as the WebUI user) would make every daemon acme.sh
call exit 2 — normalizing first is the only self-heal path, since a
post-run normalize is unreachable while acme.sh cannot start.
Files only: the directories in the tree are setgid (2775, group rwx
already), and chmodding a setgid directory issues fchmodat with the
S_ISGID bit set, which the unit's ``RestrictSUIDSGID=yes`` seccomp
filter rejects with EPERM even for root.
"""
files = [str(p) for p in _ACME_HOME.rglob("*") if p.is_file()]
if not files:
return
result = lib_common.run_proc(
["chmod", "g+rwX", *files],
sudo=True,
check=False,
timeout=10,
)
if result.returncode != 0:
logger.warning(
"Could not normalize ACME_HOME permissions: %s",
result.stderr.strip() or f"exit code {result.returncode}",
)
def _run_acme_preflight(args: list[str]) -> str:
"""Normalize ACME home permissions, then run acme.sh with *args*.
Single choke point for every daemon acme.sh invocation: the
preflight normalize makes the run succeed even if a prior run by
another user left the tree owner-only.
"""
normalize_acme_home()
return _run_acme(args)
# In-memory store for active issuance requests.
_ISSUANCES: dict[str, "IssueRequest"] = {}
@@ -546,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -561,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
def _check_account_registered() -> tuple[bool, str]:
"""Blocking check: verify an ACME account is registered.
Delegates to ``lib.state._parse_account_conf()`` which checks both
Delegates to ``daemon.collectors.acme._parse_account_conf()`` which checks both
the legacy .account.conf and the declarative config/acme/config.json
used by modern acme.sh (v3.x).
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -576,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
def _get_account_info() -> dict[str, Any]:
"""Read and return the ACME account info dict.
Delegates to ``lib.state._parse_account_conf()`` for a single
Delegates to ``daemon.collectors.acme._parse_account_conf()`` for a single
source of truth.
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
return _parse_account_conf(_ACME_HOME)
@@ -761,8 +807,11 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
"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:
normalize_acme_home()
certs = lib.acme.list_certs()
except RuntimeError as exc:
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc
@@ -839,14 +888,16 @@ async def _run_issue(req: IssueRequest) -> None:
args.append("--force")
# acme.sh is a blocking subprocess — run it off the event loop so
# polling, WS broadcasts, and other requests keep responding.
output = await asyncio.to_thread(_run_acme, args)
output = await asyncio.to_thread(_run_acme_preflight, args)
normalize_acme_home()
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
_run_acme_preflight,
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -951,7 +1002,9 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
args: list[str] = ["--renew", "-d", req.domain]
if force:
args.append("--force")
output = await asyncio.to_thread(_run_acme, args)
output = await asyncio.to_thread(_run_acme_preflight, args)
# acme.sh hardens its tree even when it skips — normalize first.
normalize_acme_home()
if "Skipping." in output:
req.steps[0].status = "done"
req.steps[0].message = "Renewal not yet due — skipped"
@@ -968,7 +1021,8 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
_run_acme_preflight,
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -1003,7 +1057,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
_run_acme(["--remove", "-d", domain])
_run_acme_preflight(["--remove", "-d", domain])
logger.info("Certificate for %s removed", domain)
refresh_state(["acme"])
return {"domain": domain}
@@ -1021,7 +1075,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
email = body.get("email", "").strip()
if not email:
raise ValueError("'email' is required")
_run_acme(["--register-account", "-m", email])
_run_acme_preflight(["--register-account", "-m", email])
# Persist to declarative ACME config
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
@@ -1159,7 +1213,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
raise ValueError("Invalid email format")
server = (body.get("server") or "letsencrypt").strip()
_run_acme(["--register-account", "-m", email, "--server", server])
_run_acme_preflight(["--register-account", "-m", email, "--server", server])
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
@@ -1179,7 +1233,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
try:
_run_acme(["--deactivate-account"])
_run_acme_preflight(["--deactivate-account"])
except RuntimeError as exc:
logger.warning("acme.sh deactivate failed: %s", exc)
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
+26
View File
@@ -0,0 +1,26 @@
"""Shared helpers for daemon handlers."""
from typing import Any
from daemon.server import refresh_state
from lib.sync import SyncEvent, bus
def emit_and_refresh(
subsystem: str, payload: dict[str, Any] | None = None
) -> list[str]:
"""Emit a ``config_saved`` sync event and refresh the affected state.
All mutation handlers end with the same tail: emit the event, refresh
the source subsystem plus every subsystem the sync touched.
Args:
subsystem: Source subsystem name.
payload: Event payload (e.g. ``{"action": "zone_created"}``).
Returns:
Subsystems affected by the sync event.
"""
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
refresh_state([subsystem, *sync_result.affected_subsystems])
return sync_result.affected_subsystems
+16 -75
View File
@@ -8,6 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
@@ -24,7 +25,7 @@ from daemon.iface import (
POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import (
deep_merge,
ensure_dirs,
@@ -35,7 +36,6 @@ from lib.common import (
stamp_applied,
strip_apply_meta,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body:
raise ValueError("Request body required")
_save_config(body)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_saved"})
return {"config_saved": True}
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_patched"})
return {"config_saved": True}
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
return {"applied": True, "synced": synced}
@registry.register(GET_DNSMASQ_STATUS)
@@ -281,12 +272,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
entry["dns"] = body["dns"]
ranges.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -321,12 +307,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -365,26 +346,14 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
if hostname is not None:
leases[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
@@ -409,12 +378,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
return {"mac": mac}
@@ -440,26 +404,14 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
if hostname is not None:
records[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
@@ -482,12 +434,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
return {"name": name}
@@ -503,10 +450,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
return {"upstreams": cfg["dns"]["upstreams"]}
@@ -523,8 +467,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "domain_set"})
return {"domain": cfg["dns"]["domain"]}
+131 -136
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -33,7 +34,7 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
@@ -43,11 +44,11 @@ from lib.firewall import (
_parse_all_zones_output,
_parse_zone_output,
fw_change_summary,
validate_coverage,
)
from lib.firewall import (
save_backup as _save_backup,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg, indent=2)
def _check_coverage(cfg: dict[str, Any]) -> None:
"""Reject a config that leaves a managed interface without coverage.
Runs the pure ``validate_coverage`` invariant against the current
network config. ``lo`` and ``wg*`` are exempt, and interfaces declared
in the top-level ``unmanaged`` list are exempt.
Args:
cfg: The (merged or full) firewall config dict to validate.
Raises:
ValueError: If a network-managed interface is not covered by any
zone and is not declared under ``unmanaged``.
"""
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ValueError(
"Refusing to save: "
f"{', '.join(repr(n) for n in uncovered)} "
f"have no firewall zone coverage and are not declared in the "
f"'unmanaged' list. Assign each interface to a zone, or add it "
f"to the top-level 'unmanaged' list."
)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
@@ -150,11 +176,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so
their current live interfaces count as coverage, as do the live
interfaces of zones that are live but absent from the config.
- the config leaves a network-subsystem-managed interface with no
firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
The config is the source of truth for zone interfaces — an absent
``interfaces`` key counts as empty — so coverage is computed from the
config alone via ``validate_coverage`` with no live-state fallback.
Interfaces listed in the top-level ``unmanaged`` key are exempt. The
same invariant is enforced at save time (POST/PATCH /firewall/config),
so a conflict here means the network config changed after the firewall
config was saved (e.g. a new interface no zone covers).
"""
from lib.firewall import get_config as _get_lib_config
@@ -178,37 +208,20 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
f'to the zone\'s services, or pass {{"force": true}}.'
)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
# Coverage invariant: every network-managed interface must be
# covered by a zone in the config (or declared unmanaged), or
# traffic (and DHCP) on that segment is dropped. Pure config check
# — the config is the source of truth, so no live-state comparison.
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
f"have no firewall zone coverage in the config and are not "
f"declared unmanaged, so all traffic (including DHCP) from "
f"those segments would be dropped. Assign each interface to "
f"a zone (or list it under the config's top-level 'unmanaged' "
f'key), or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
@@ -297,38 +310,36 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# The config is the source of truth: an absent "interfaces" key
# counts as an empty list (unassign-all), matching the coverage
# invariant and the pending diff.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
@@ -576,19 +587,20 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty, missing ``zones`` key,
or ``zones`` is not a dict.
ValueError: If body is empty, missing ``zones`` key, ``zones`` is
not a dict, ``unmanaged`` is not a list, or the config leaves a
network-managed interface without zone coverage.
"""
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
_check_coverage(body)
_save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_saved"})
return {"config_saved": True}
@@ -604,20 +616,22 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty.
ValueError: If body is empty, ``unmanaged`` is not a list, or the
merged config leaves a network-managed interface without zone
coverage.
"""
if not body:
raise ValueError("Request body must be a JSON object")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_check_coverage(merged)
_save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys()))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_patched"})
return {"config_saved": True}
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
default zone, or would remove zone coverage from a
network-managed interface that is covered now, and ``force`` is
not set.
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
result["synced"] = sync_result.affected_subsystems
synced = emit_and_refresh("firewall", {"action": "config_applied"})
result["synced"] = synced
return result
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
return {"zone": zone_name}
@@ -754,10 +761,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Zone '%s' deleted", zone)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
return {"zone": zone}
@@ -856,15 +860,13 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
old_zone_cfg["interfaces"] = new_ifaces
elif "interfaces" in old_zone_cfg:
del old_zone_cfg["interfaces"]
# This mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
return {"zone": zone, "interfaces": interfaces}
@@ -927,15 +929,15 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
_reload()
# Keep the declarative config in sync so the next apply does not
# reconcile the live services back to the stale config value.
# reconcile the live services back to the stale config value. The
# mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
return {"zone": zone, "services": services}
@@ -979,13 +981,9 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
rule_id = uuid4().hex[:8]
entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
return {"zone": zone, "id": rule_id, "rule": rule}
@@ -1035,13 +1033,9 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
zone_cfg["rich_rules"] = [
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
return {"zone": zone, "id": rule_id}
@@ -1084,6 +1078,10 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Enable/disable masquerade on a zone.
Also syncs the declarative config (and re-stamps the applied baseline)
when the zone exists in the config, so the pending diff and cancel-all
stay consistent with the live zone.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``enable`` (boolean).
@@ -1108,12 +1106,17 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
# Keep the declarative config in sync with the live zone so the pending
# diff and the cancel-all baseline stay consistent. Only touch zones that
# already exist in the config — creating a bare zone entry would
# manufacture spurious service/interface diffs on the next poll.
cfg = _get_config()
zone_cfg = cfg.get("zones", {}).get(zone)
if isinstance(zone_cfg, dict):
zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg)
_save_config(cfg)
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
return {"zone": zone, "masquerade": bool(enable)}
@@ -1161,20 +1164,16 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
_reload()
fp_id = uuid4().hex[:8]
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
if toaddr:
if toaddr and toport:
entry["toaddr"] = toaddr
if toport:
entry["toport"] = int(toport)
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@@ -1233,13 +1232,9 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
return {"zone": zone, "port": int(port), "proto": proto}
+8 -17
View File
@@ -10,6 +10,7 @@ import re
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
@@ -20,7 +21,7 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import run, stamp_applied, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
@@ -36,7 +37,6 @@ from lib.network import (
render_network_file,
save_config,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -220,16 +220,13 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
)
synced = emit_and_refresh(
"networkd", {"action": "interface_saved", "interface": name}
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {
"name": name,
"applied": deployed,
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -298,10 +295,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
synced = emit_and_refresh("networkd", {"action": "config_applied"})
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
@@ -312,7 +306,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -366,8 +360,5 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
return {"name": name, "value": value}
+1
View File
@@ -293,6 +293,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any])
cert_key_path=cert_key_path,
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
has_management=has_management,
static_root=str(PROJECT_DIR / "webui" / "static"),
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
+12 -1
View File
@@ -128,12 +128,20 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
Args:
_request: The incoming HTTP request (unused).
_body: Optional JSON body; ``{"force": true}`` is forwarded to the
firewall apply, overriding its management-lockout and
interface-coverage guards. Other subsystems ignore it.
Returns:
Dict with applied subsystems and any errors encountered.
"""
applied = []
errors = {}
force = bool(_body and _body.get("force"))
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
@@ -153,7 +161,10 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
handler = SYS_APPLY[name]
try:
handler(None, None)
# Only the firewall apply honors `force` (its lockout and
# coverage guards); forward it there, not to other subsystems.
body = {"force": True} if (name == "firewall" and force) else None
handler(None, body)
applied.append(name)
except Exception as exc:
label = SYS_LABELS.get(name, name)
+15 -61
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
@@ -27,9 +28,8 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
from lib.sync import SyncEvent, bus
from lib.wireguard import (
_class_interface_name,
_class_peers,
@@ -122,10 +122,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_saved"})
return {"config_saved": True}
@@ -153,10 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_wireguard_config()
merged = deep_merge(current, body)
_save_wireguard_config(merged)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_patched"})
return {"config_saved": True}
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_wireguard_config()
stamp_applied(cfg_after)
_save_wireguard_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
synced = emit_and_refresh("wireguard", {"action": "config_applied"})
return {
"applied": True,
"synced": sync_result.affected_subsystems,
"synced": synced,
"interfaces": affected,
}
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
except Exception:
pass
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "tunnel_down"})
return {"down": True}
@@ -296,12 +284,7 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
return {"up": True, "interface": ifname}
@@ -328,14 +311,7 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent(
"wireguard",
"config_saved",
{"action": "class_down", "class_key": class_key},
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
return {"down": True, "interface": ifname}
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
_save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "initialized"})
safe = dict(cfg)
safe["interface"] = dict(safe["interface"])
@@ -467,12 +440,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added"
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
peer_out = dict(peers[name])
peer_out.pop("private_key", None)
return peer_out
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name]
_save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
return {"name": name}
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"public_key": "",
}
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_created"})
out = dict(classes[key])
out.pop("private_key", None)
return out
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if field in body:
class_cfg[field] = body[field]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_updated"})
out = dict(classes[key])
out.pop("private_key", None)
return {"key": key, **out}
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
del classes[key]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_deleted"})
return {"key": key}
+30 -18
View File
@@ -18,6 +18,7 @@ from typing import Any
from aiohttp import web
import daemon.collectors # noqa: F401 (registers state collectors)
from daemon.iface import PathLike
from lib.auth import blacklist_expired
from lib.state import _DEFAULT_POLL_INTERVALS
@@ -145,16 +146,20 @@ class Registry:
registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
def refresh_state(subsystems: list[str] | None = None, bump: bool = True) -> None:
"""Refresh the pre-computed state for the given subsystems (or all).
Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
bump: Bump the version counter for each refreshed subsystem.
``refresh_status`` passes ``False`` — versions advance on
structural poll diffs and on mutation-triggered refreshes only.
"""
state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS
for name in targets:
state_store.bump(name)
if bump:
for name in targets:
state_store.bump(name)
try:
asyncio.get_running_loop()
except RuntimeError:
@@ -600,22 +605,11 @@ async def refresh_status(_request: web.Request) -> web.Response:
except (json.JSONDecodeError, ValueError):
body = None
subsystems = body.get("subsystems") if body else None
state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS
snapshot = {name: state_store.get(name) for name in targets}
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
# Deliberately no version bump — versions advance on structural poll
# diffs and on refresh_state() only.
async def _broadcast_all():
await asyncio.gather(
*[broadcast_versions(name) for name in targets],
return_exceptions=True,
)
task = asyncio.create_task(_broadcast_all())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
refresh_state(subsystems, bump=False)
targets = subsystems or state_store.SUBSYSTEMS
snapshot = {name: state_store.get(name) for name in targets}
return ok(snapshot)
@@ -698,7 +692,7 @@ def main() -> None:
_stop_polling()
# Suppress the default exception handler during teardown so that
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
prev_handler = loop.exception_handler
prev_handler = loop.get_exception_handler()
loop.set_exception_handler(_teardown_exception_handler)
try:
# Stop accepting new connections (also waits for open sockets,
@@ -747,6 +741,24 @@ def main() -> None:
if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
# Filesystem bootstrap after the import (which must see absent config
# files to adopt live system state on first start): create runtime
# directories and persist the one-shot nginx legacy-format migration.
from lib.bootstrap import bootstrap
bootstrap()
# Reopen group access on the ACME home before the first acme.sh
# collection: a tree left owner-only by a prior run (e.g. a manual
# run as the WebUI user) would otherwise fail every daemon acme.sh
# call until the next issue/renew. Never fatal at startup.
try:
from daemon.handlers.acme import normalize_acme_home
normalize_acme_home()
except Exception:
logger.warning("ACME home normalization failed at startup", exc_info=True)
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
+387 -127
View File
@@ -1,6 +1,6 @@
# REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <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`.
@@ -17,7 +17,7 @@ Most endpoints require a valid JWT access token. The token is obtained by loggin
### Permission Checks
Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`.
Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. A request with no permission entry for its subsystem (or a method/level mismatch) is rejected with HTTP `403`. User management endpoints (`/api/auth/users/*`) and credential counts (`/api/auth/webauthn/credential-counts`) follow the same rule: `GET` needs only `auth: "read"`, while `POST`/`PATCH`/`DELETE` need `auth: "rw"`.
## Conventions
@@ -46,6 +46,8 @@ Error responses carry one of the following HTTP status codes:
| Code | Meaning |
|------|---------|
| `400` | Bad request — invalid body, missing required field, or malformed value |
| `401` | Unauthorized — missing/invalid `Bearer` token, missing `X-Session-Id` header, or invalid/expired/blacklisted token |
| `403` | Forbidden — the caller lacks the required subsystem permission (`auth: "rw"` where needed, or no entry for the subsystem) |
| `404` | Not found — the requested resource does not exist |
| `409` | Conflict — the requested operation conflicts with an existing resource |
| `500` | Internal server error — unexpected failure in the backend |
@@ -123,7 +125,7 @@ Invalidate the current session by blacklisting the access token.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
**Response:** `data` is `{}` (an empty object) on success.
#### Refresh Tokens
@@ -131,9 +133,16 @@ Invalidate the current session by blacklisting the access token.
POST /api/auth/refresh
```
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens.
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens. The request body must carry **both** `refresh_token` and `session_id` (session binding).
**Auth:** Refresh token required.
**Auth:** Public — no JWT required (this is a public endpoint, so the `X-Session-Id` header is not sent).
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `refresh_token` | `string` | Yes | The refresh token to rotate |
| `session_id` | `string` | Yes | Session ID from the token pair (session binding) |
**Response (`data`):**
@@ -162,11 +171,11 @@ Change the current user's password.
|---|---|---|---|
| `username` | `string` | No | Auto-injected from JWT context |
| `oldPassword` | `string` | Yes | Current password |
| `newPassword` | `string` | Yes | New password |
| `newPassword` | `string` | Yes | New password (minimum 8 characters) |
**Response:** `data` is `null` on success.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `400` if old password is incorrect.
Returns HTTP `400` for any failure — missing fields, incorrect old password, or a new password shorter than 8 characters.
---
@@ -178,15 +187,11 @@ Returns HTTP `400` if old password is incorrect.
GET /api/auth/users
```
List all users. Requires admin permission (`auth: "rw"`).
List all users.
**Auth:** `auth: "rw"` required.
**Auth:** `auth: "read"` required (read-only endpoint).
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
**Response (`data`):** the array of user summaries directly (no `users` wrapper). Each entry has `id`, `username`, `permissions` (`{ subsystem: "read" | "rw" }`), and `created_at`.
#### Create User
@@ -203,7 +208,7 @@ Create a new user with password and per-subsystem permissions.
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password |
| `password` | `string` | Yes | Plain-text password (minimum 8 characters) |
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response (`data`):**
@@ -214,7 +219,7 @@ Create a new user with password and per-subsystem permissions.
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `409` if username already exists.
Returns HTTP `409` if the username already exists. Returns HTTP `400` if the password is missing or shorter than 8 characters.
#### Update User
@@ -254,12 +259,37 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if user not found.
Returns HTTP `403` if the user attempts to delete their own account. Returns HTTP `404` if the user is not found.
---
### WebAuthn
#### Check WebAuthn Capability
```
GET /api/auth/webauthn/capable
```
Check whether WebAuthn is available on the current request domain (the relying-party ID is derived from the request host).
**Auth:** Access token required.
**Response (`data`):**
When enabled:
| Field | Type | Description |
|---|---|---|
| `enabled` | `boolean` | Always `true` |
| `rp_id` | `string` | Relying-party ID (request host) |
| `rp_name` | `string` | Relying-party display name |
| `origin` | `string` | Resolved WebAuthn origin (`scheme://host`) |
When unavailable: `{"enabled": false, "reason": "<reason>"}`.
---
#### Begin Registration
```
@@ -274,9 +304,9 @@ Start WebAuthn credential registration. Returns options for `navigator.credentia
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to register for |
| `username` | `string` | No | Auto-injected from the JWT (the authenticated user); any value in the body is overridden |
**Response (`data`):**
**Response (`data`):** Standard WebAuthn registration options.
| Field | Type | Description |
|---|---|---|
@@ -299,13 +329,19 @@ Complete WebAuthn credential registration. Verifies the attestation response and
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `response` | `object` | Yes | WebAuthn authenticator attestation response |
| `username` | `string` | No | Auto-injected from the JWT; any value in the body is overridden |
| `credential_response` | `object` | Yes | WebAuthn authenticator attestation response |
| `registration_options` | `object` | Yes | The registration options returned by `register-begin` |
| `name` | `string` | No | Display name for this credential |
**Response:** `data` is `null` on success.
**Response (`data`):**
Returns HTTP `400` if verification fails.
| Field | Type | Description |
|---|---|---|
| `ok` | `boolean` | Always `true` |
| `credential` | `object` | The stored credential (`id`, `name`, `transports`, `sign_count`) |
Returns HTTP `400` if verification fails or required fields are missing.
#### Begin Authentication
@@ -359,7 +395,7 @@ Complete WebAuthn authentication. Verifies the assertion and issues tokens on su
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `400` if verification fails.
Returns HTTP `401` if verification fails or required fields are missing.
#### List Credentials
@@ -373,7 +409,7 @@ List WebAuthn credentials for the current user.
**Response (`data`):**
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`).
Array of credential objects (`id`, `name`, `transports`, `sign_count`).
#### Credential Counts
@@ -381,15 +417,11 @@ Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCo
GET /api/auth/webauthn/credential-counts
```
Return credential counts for all users. Admin endpoint.
Return credential counts for all users.
**Auth:** `auth: "rw"` required.
**Auth:** `auth: "read"` required (read-only endpoint).
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
**Response (`data`):** The dict directly (no `counts` wrapper) — a mapping of usernames to credential counts (`{"alice": 2, "bob": 1}`).
#### Remove Credential
@@ -401,9 +433,9 @@ Remove a WebAuthn credential.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if credential not found.
Returns HTTP `404` if the credential is not found.
---
@@ -433,7 +465,9 @@ POST /api/firewall/config
Replace the declarative config. Returns pending changes summary.
**Request Body:** Request body must contain `zones`.
**Request Body:** Request body must contain `zones`. An optional top-level `unmanaged` array (list of interface names) exempts those interfaces from the interface-coverage invariant.
**Errors:** Returns HTTP `400` when the body is malformed (missing/non-dict `zones`, non-list `unmanaged`) or when the config would leave a network-managed interface without zone coverage (the interface-coverage invariant — see `docs/config.md`).
**Response (`data`):**
@@ -452,6 +486,10 @@ POST /api/firewall/config/apply
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**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`.
**Response (`data`):**
| Field | Type | Description |
@@ -466,9 +504,18 @@ Apply the declarative config to live firewalld. Applies targets, services, inter
GET /api/firewall/config/pending
```
Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
Compare declarative config against live firewalld state. Returns the diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
**Response:** Same structure as POST /config response.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `pending` | `[object, ...]` | List of pending changes |
| `needs_apply` | `boolean` | Whether changes need to be applied |
| `unmanaged_zones` | `object` | Zones active on the system but not present in the config |
| `pending_summary` | `[string, ...]` | Human-readable summary string per pending change |
Unlike the `POST`/`PATCH /config` save response, this endpoint does **not** include `config_saved`; instead it adds `pending_summary`.
#### Partial Update Config
@@ -478,6 +525,10 @@ PATCH /api/firewall/config
Deep-merge the provided fields into the existing config. Returns pending changes summary.
**Request Body:** Partial config object; a provided `unmanaged` array replaces the existing one.
**Errors:** Returns HTTP `400` when the merged config is malformed or would leave a network-managed interface without zone coverage (interface-coverage invariant — see `docs/config.md`).
**Response (`data`):**
| Field | Type | Description |
@@ -518,13 +569,18 @@ Return detailed configuration for a single zone.
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Zone name |
| `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) |
| `interfaces` | `[string, ...]` | Interfaces assigned to this zone |
| `sources` | `[string, ...]` | Source IPs addressed by this zone |
| `services` | `[string, ...]` | Services allowed through the zone |
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
| `protocols` | `[string, ...]` | Protocols to accept |
| `icmp-blocks` | `[string, ...]` | ICMP types blocked |
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
| `ics` | `boolean` | Whether ICMP redirect (ICS) is enabled |
| `forward-ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules (key is hyphenated) |
| `rich-rules` | `[string, ...]` | Rich rule strings (key is hyphenated) |
Returns HTTP `404` if the zone does not exist.
@@ -586,6 +642,8 @@ Replace all interfaces assigned to the zone with the provided list.
| `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned |
Returns HTTP `404` if the zone does not exist.
---
#### Set Zone Services
@@ -601,6 +659,7 @@ Replace all services allowed in the zone with the provided list.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names |
| `force` | `boolean` | No | Override the management-lockout guard |
**Response (`data`):**
@@ -609,6 +668,8 @@ Replace all services allowed in the zone with the provided list.
| `zone` | `string` | Zone name |
| `services` | `[string, ...]` | List of services now allowed |
Returns HTTP `404` if the zone does not exist. Returns HTTP `409` if the change would strip both https and ssh from the default zone (the management-lockout guard) and `force` is not set.
### Rich Rules
#### Add Rich Rule
@@ -661,13 +722,13 @@ Returns HTTP `404` if the rule ID is not found.
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:**
| 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
@@ -742,6 +803,8 @@ Toggle masquerade (source NAT) for a zone.
| `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled |
Returns HTTP `400` when attempting to enable masquerade on the `public` zone (it is not supported there — use `internal` or `vpn`).
### State
#### Get Firewall State
@@ -856,7 +919,7 @@ Deep-merge the provided fields into the existing configuration. Useful for targe
POST /api/dhcp/apply
```
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and **restart** the dnsmasq service (`systemctl restart dnsmasq`, not a reload).
**Response:** `data` is `null` on success.
@@ -868,22 +931,17 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa
GET /api/dhcp/status
```
Return the current service status, config summary, and active lease count.
Return the current service status and pending-change summary.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `service_active` | `boolean` | Whether dnsmasq is running |
| `config_file_exists` | `boolean` | Whether config file exists on disk |
| `config_in_sync` | `boolean` | Whether disk config matches expected |
| `dhcp_ranges` | `number` | Number of DHCP ranges |
| `static_leases` | `number` | Number of static leases |
| `custom_dns_records` | `number` | Number of custom DNS records |
| `upstreams` | `[string, ...]` | Upstream DNS servers |
| `domain` | `string` | Local DNS domain |
| `config_file_exists` | `boolean` | Whether the config file exists on disk |
| `active_leases` | `number` | Number of active leases |
| `leases` | `[object, ...]` | Active lease objects |
| `pending_changes` | `boolean` | Whether the saved config differs from the last applied state |
| `pending_diff` | `[object, ...]` | Per-field pending changes (diff of config vs applied baseline) |
### DHCP Ranges
@@ -920,12 +978,14 @@ Remove a DHCP range. Body contains identifying fields.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | Yes | Interface name |
| `interface` | `string` | No | Interface name; defaults to `""` (all interfaces) |
| `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range |
**Response:** `data` is `null` on success.
Returns HTTP `404` if no range matches the given interface/start/end.
### Static Leases
#### Add Static Lease
@@ -1024,6 +1084,26 @@ Returns HTTP `404` if no matching record is found.
---
### DNS Search Domain
#### Set Search Domain
```
POST /api/dhcp/domain
```
Set or clear the DNS search domain. Pass `domain` to set it, or `null` to clear it.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | No | DNS search domain; `null` clears it |
**Response:** `data` is `null` on success.
---
## Proxy API
Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy.
@@ -1112,7 +1192,7 @@ Return all configured proxy domains. The response is flattened by path — each
|-------|------|-------------|
| `data` | `[object, ...]` | Array of path-level domain configuration objects |
Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags.
Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `backend_name` (string — the name of the referenced backend), `cert` (string or `null`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags.
---
@@ -1122,27 +1202,17 @@ Each entry contains `domain` (string), `path` (string), `backend` (object with `
POST /api/proxy/domains
```
Add a new reverse proxy domain. Accepts two modes:
Add a new reverse proxy domain that routes to a named backend. The "paths mode" / "legacy mode" split no longer exists — domains reference a backend by name and per-path routing lives on the backend itself.
**Paths mode (preferred):**
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy |
| `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. |
| `backend` | `string` | Yes | Name of an existing backend (a key under `backends`) |
| `cert` | `string` | No | Certificate type |
| `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) |
**Legacy mode (backward compatible):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy |
| `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
| `cert` | `string` | No | Certificate type |
| `extra_headers` | `object` | No | Extra proxy headers |
| `auth` | `object` | No | Basic auth as `{user, pass}`; when both are present a `.htpasswd` file is written as a side-effect and the raw password is **not** persisted (only `{user, htpasswd: <path>}` is stored) |
**Response (`data`):**
@@ -1150,7 +1220,7 @@ Add a new reverse proxy domain. Accepts two modes:
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `400` if the domain is already configured.
Returns HTTP `400` if the domain is already configured, if `domain` or `backend` is missing, or if the referenced backend does not exist.
---
@@ -1160,9 +1230,14 @@ Returns HTTP `400` if the domain is already configured.
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`):**
@@ -1170,7 +1245,7 @@ Update one or more fields of an existing domain entry. Only fields present in th
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `404` if the domain is not configured.
Returns HTTP `404` if the domain is not configured. Returns HTTP `400` if the body is empty or the new `backend` does not exist.
---
@@ -1190,6 +1265,86 @@ Remove a proxy domain and its nginx configuration.
Returns HTTP `404` if the domain is not configured.
### Backend Management
Backends define the per-path routing (`paths`) and any basic auth; proxy domains reference a backend by name.
#### List All Backends
```
GET /api/proxy/backends
```
Return all configured backends. Secret material is stripped — each backend carries a `has_auth` boolean instead of its `auth` object.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Map of backend name to `{label, paths, has_auth, builtin?}` (auth stripped) |
---
#### Update Backend
```
PATCH /api/proxy/backends
```
Deep-merge a partial update into an existing backend entry. Built-in backends cannot be modified.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Backend name to update |
| `label` | `string` | No | New display label |
| `paths` | `object` | No | New path-to-backend map |
| `auth` | `object` \| `false` \| `null` | No | Set basic auth, or `false`/`null` to remove it |
**Response (`data`):** `{"backend": "<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 Configuration
@@ -1249,7 +1404,7 @@ Return all managed certificates with metadata.
|-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects |
Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
Each certificate object contains `domain`, `issuer` (the CA/issuer name), `san_domains` (array of subject-alternative names), `expires_at`, `days_until_expiry`, `cert_path`, `key_path`, `ca_path`, and `auto_renew`.
---
@@ -1259,11 +1414,13 @@ Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `c
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
@@ -1308,6 +1465,8 @@ Create a new certificate issuance request. Issuance runs asynchronously in the b
| Field | Type | Description |
|-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling issuance status |
| `domain` | `string` | Domain being issued |
| `status` | `string` | Only present when an issuance for this domain is already running — `"existing"` (the existing `request_id` is returned) |
Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
@@ -1337,7 +1496,11 @@ Start an async certificate renewal for an existing certificate. The renewal
runs in the background and is polled via
`GET /api/certs/renew/<request_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`):**
@@ -1382,11 +1545,11 @@ is skipped, or fails.
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.
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
@@ -1443,7 +1606,7 @@ Returns HTTP `400` if the email is missing or invalid. Returns HTTP `500` if reg
DELETE /api/certs/account
```
Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`.
Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`. A failure in the `acme.sh` call is caught and logged but **does not** fail the endpoint — the config cleanup always runs.
**Response (`data`):**
@@ -1451,8 +1614,6 @@ Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `emai
|-------|------|-------------|
| `email` | `string` | Empty string indicating the account was deactivated |
Returns HTTP `500` if deactivation fails.
---
#### Set ACME Contact Email
@@ -1471,13 +1632,11 @@ Set or update the ACME account contact email.
**Response (`data`):** Returns the set `email` field.
#### Generate Self-Signed Certificate
#### Generate Self-Signed Certificate (daemon-only)
```
POST /api/certs/self-signed
```
There is **no** `POST /api/certs/self-signed` webui route. Self-signed generation is a daemon-only endpoint, `POST /acme/self-signed` (reached directly over the daemon socket, not via the WebUI).
Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `<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:**
@@ -1491,9 +1650,9 @@ Generate a self-signed certificate for a domain. Idempotent — skips if `fullch
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
| `cert` | `string` | Path to `fullchain.cer` |
| `key` | `string` | Path to `<domain>.key` |
| `generated` | `boolean` | `true` if a new cert was created, `false` if existing cert was reused |
| `cert` | `string` | Path to `data/certs/<domain>.crt` |
| `key` | `string` | Path to `data/certs/<domain>.key` |
| `generated` | `boolean` | `true` if a new cert was created, `false` if the existing cert was reused |
## WireGuard API
@@ -1583,14 +1742,9 @@ Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
POST /api/wireguard/down
```
Bring down the WireGuard tunnel interface (`wg0`).
Bring down the WireGuard tunnel interface(s) (all class interfaces plus the legacy `wg0`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `down` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems auto-synced as a result |
**Response:** `data` is `null` on success (the webui route discards the daemon payload). The daemon itself returns `{"down": true}` — it does not include a `synced` field.
### Status
@@ -1609,6 +1763,7 @@ Return live tunnel state with interface metrics and per-peer connection statisti
| `up` | `boolean` | Whether the tunnel interface is up |
| `interface` | `object` | Interface info (listen port, public key) |
| `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) |
| `classes` | `object` | Per-class runtime status keyed by class key (`{up, interface, peers}`) |
---
@@ -1646,7 +1801,7 @@ Return all configured peers. Private keys are stripped.
POST /api/wireguard/peers
```
Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response.
Add a new WireGuard peer, or **upsert** an existing one — if the `name` is already configured, the provided fields update that peer in place (a key pair is only generated for genuinely new peers). Private key stripped from response.
**Request Body:**
@@ -1654,11 +1809,13 @@ Add a new WireGuard peer. A key pair is auto-generated. Private key stripped fro
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer identifier name |
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `[]` |
| `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) |
| `preshared_key` | `string` | No | Preshared key |
| `description` | `string` | No | Peer description |
| `access_class` | `string` | No | Access class key this peer belongs to |
**Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
**Response (`data`):** The peer object with `public_key`, `endpoint`, `allowed_ips`, `persistent_keepalive`, `preshared_key`, `description`, `access_class` (no `private_key`).
---
@@ -1731,11 +1888,11 @@ Manage VPN access classes that categorize peers by access level (e.g., full LAN
GET /api/wireguard/classes
```
Return all configured access classes.
Return all configured access classes. Private keys are stripped.
**Response (`data`):**
Object keyed by class identifier, each with `name` and `description` fields.
Object keyed by class identifier, each entry carrying `name`, `description`, `subnet`, `listen_port`, `lan_access`, and `public_key` (private key omitted).
#### Create Access Class
@@ -1749,19 +1906,16 @@ Create a new access class.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier (alphanumeric) |
| `key` | `string` | Yes | Class identifier (lowercase alphanumeric) |
| `name` | `string` | No | Display name (defaults to key) |
| `description` | `string` | No | Description text |
| `subnet` | `string` | No | Class subnet (CIDR) |
| `listen_port` | `number` | No | Listen port for the class interface |
| `lan_access` | `boolean` | No | Whether peers get LAN access (default `false`) |
**Response (`data`):**
**Response (`data`):** The created class object (`name`, `description`, `subnet`, `listen_port`, `lan_access`, `public_key`) — note there is **no** `key` field in the response; the class is keyed by the request `key`.
| Field | Type | Description |
|-------|------|-------------|
| `key` | `string` | Class key |
| `name` | `string` | Display name |
| `description` | `string` | Description |
Returns HTTP `409` if the key already exists.
Returns HTTP `400` if the `key` is missing or is not lowercase alphanumeric. Returns HTTP `409` if the key already exists.
#### Update Access Class
@@ -1769,7 +1923,7 @@ Returns HTTP `409` if the key already exists.
PATCH /api/wireguard/classes
```
Update an existing access class.
Update an existing access class. Only the fields present in the body are changed.
**Request Body:**
@@ -1778,8 +1932,11 @@ Update an existing access class.
| `key` | `string` | Yes | Class identifier |
| `name` | `string` | No | New display name |
| `description` | `string` | No | New description |
| `subnet` | `string` | No | New subnet (CIDR) |
| `listen_port` | `number` | No | New listen port |
| `lan_access` | `boolean` | No | New LAN access flag |
**Response (`data`):** Updated class object with `key`, `name`, `description`.
**Response (`data`):** The updated class object `key` plus `name`, `description`, `subnet`, `listen_port`, `lan_access`, and `public_key`.
Returns HTTP `404` if the class is not found.
@@ -1803,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
Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters.
@@ -1861,8 +2074,9 @@ Save network config for an interface, render the `.network` file, copy it to `/e
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
| `applied` | `boolean` | Always `true` |
The webui response is a fixed `{ "name": ..., "applied": true }` — it never reports `false` and carries no `synced` field (the daemon returns `applied`/`synced` internally, but the webui transform flattens it to this).
Returns HTTP `400` if the interface name is invalid.
@@ -1874,7 +2088,7 @@ Returns HTTP `400` if the interface name is invalid.
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`):**
@@ -1940,7 +2154,7 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
Returns HTTP `500` if the value cannot be verified after write.
This is a read-only suggestion endpoint; it does not write anything and returns no write-verification errors.
---
@@ -1976,6 +2190,16 @@ POST /api/status/apply-all
Apply pending changes for all subsystems in dependency order.
**Request Body (optional):**
```json
{ "force": true }
```
`force` is forwarded to the firewall apply only — it overrides the
management-lockout guard and the interface-coverage invariant. Other
subsystems ignore it.
**Response (`data`):**
| Field | Type | Description |
@@ -1983,10 +2207,17 @@ Apply pending changes for all subsystems in dependency order.
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `object` | Map of subsystem label → error message |
The firewall apply runs with `force=false`, so if a firewall interface
would be left without zone coverage (the coverage guard), a
`ConflictError` surfaces in `errors` under `"Firewall"` while the other
subsystems proceed — the desired no-silent-apply behavior.
The endpoint returns `200` even when some subsystems failed — per-subsystem
failures are reported in `errors`, so clients must check `errors` (not just
the HTTP status) before reporting success. Without `force`, the firewall
apply is refused when a network-managed interface has no zone coverage in
the config and is not `unmanaged` (the interface-coverage invariant) or when
the config would strip both https/ssh from the default zone (lockout guard);
the `ConflictError` surfaces in `errors` under `"Firewall"` while the other
subsystems proceed. Pending state comes from
the last state poll (firewall 30s, dnsmasq 10s, nginx 60s, wireguard 10s,
networkd 10s), so an edit saved within the last poll interval may not be
picked up by this call.
---
@@ -2003,6 +2234,14 @@ Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
Notes: pending state comes from the last state poll (firewall 30s,
dnsmasq 10s, nginx 60s, wireguard 10s, networkd 10s), so an edit saved
within the last poll interval is not yet flagged pending and is left in
place. For the firewall, pending is a config-vs-live diff: cancel
restores only the config file, so live firewalld drift made outside the
declarative config (manual `firewall-cmd`) is not reverted and the
firewall may still report pending after a cancel.
**Request Body:** none.
**Response (`data`):**
@@ -2047,21 +2286,42 @@ Re-collect state from the daemon, optionally filtered by subsystem. Proxies the
Returns HTTP `500` if the daemon is unreachable.
### Sysctl
### System Metrics
#### Set Kernel Parameter
#### Get System Metrics
```
POST /api/network/sysctl/set
GET /api/status/system-metrics
```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
Return system-wide CPU load, memory, swap, and per-interface network traffic metrics, read from the daemon's pre-collected `system` state.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `load` | `object` | Load averages (`load1`, `load5`, `load15`) |
| `memory` | `object` | Memory usage (`total`, `available`, `used`, `used_pct`) |
| `swap` | `object` | Swap usage (`total`, `used`, `used_pct`) |
| `traffic` | `object` | Per-interface network traffic stats (interface name → counters) |
---
### Sysctl (daemon-only)
There is **no** `POST /api/network/sysctl/set` webui route. Setting a sysctl kernel parameter is a daemon-only endpoint, `POST /network/sysctl/set` (reached directly over the daemon socket, not via the WebUI).
It sets the value via `sysctl -w` and verifies by reading it back. Only a fixed allowlist of nine keys is permitted:
| `net.ipv4.ip_forward` | `net.ipv4.conf.all.forwarding` | `net.ipv4.conf.all.accept_redirects` |
| `net.ipv4.conf.default.accept_redirects` | `net.ipv4.conf.all.send_redirects` | `net.ipv4.conf.default.send_redirects` |
| `net.ipv4.conf.all.rp_filter` | `net.ipv4.icmp_echo_ignore_all` | `net.ipv4.tcp_syncookies` |
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) |
| `name` | `string` | Yes | Kernel parameter name (must be one of the nine allowed keys) |
| `value` | `string` | Yes | Value to set |
**Response (`data`):**
@@ -2071,7 +2331,7 @@ Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it b
| `name` | `string` | Parameter name |
| `value` | `string` | Value set |
Returns HTTP `500` if the value cannot be verified after write.
Returns HTTP `400` if `name`/`value` is missing, `name` is malformed, or `name` is not in the allowlist. Returns HTTP `500` if the value cannot be verified after write.
---
+163 -82
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).
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `config/nginx/config.json`.
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
6. The backend service processes the request and returns an HTTP response.
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface.
5. If the domain has an `auth` block, nginx applies HTTP Basic authentication before proxying. Auth is resolved in the order domain → backend (the effective `auth` is the domain's own, or the referenced backend's if the domain has none), and per-path behavior follows the resolved auth config. Credentials are checked against the generated `data/nginx/.htpasswd` file; unauthenticated requests receive a 401 with a `WWW-Authenticate` challenge. Domains without an `auth` block skip this step entirely.
6. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via a `proxy_pass` directive (per-path upstreams resolved from the backend's `paths` config).
7. The backend service processes the request and returns an HTTP response.
8. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
9. nginx encrypts the response with TLS and sends it back to the client through the WAN interface.
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
@@ -21,7 +22,7 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
1. A client sends an HTTPS request to the management domain.
2. nginx terminates TLS and proxies the request to `127.0.0.1:9090` where the Flask WebUI is listening. No nginx-level authentication is applied.
3. Flask validates the JWT from the `Authorization: Bearer <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.
5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection.
@@ -33,28 +34,40 @@ Because Flask binds only to `127.0.0.1`, it is unreachable directly from any ext
The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:
```
External Client ──→ nginx (SSL termination, NO auth) ──→ Flask WebUI (127.0.0.1:9090, JWT + permission check)
External Client ──→ nginx (SSL termination; auth_basic only on proxy domains with an `auth` block) ──→ Flask WebUI (127.0.0.1:9090, JWT + X-Session-Id + permission check)
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db)
vacuum-walld ──→ daemon/handlers/auth.py ──→ lib/auth.py ──→ JWT operations
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
vacuum-walld ──→ daemon/handlers/nginx.py ──→ render temps in /run/vacuum-wall ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload (SIGHUP)
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ /run/vacuum-wall/dnsmasq.tmp ──→ sudo cp to /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ on issue/renew success: deploy hook (sudo nginx -t && sudo nginx -s reload) ──→ nginx
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render per-class config (wg-<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 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/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
Vacuum Wall uses two distinct system users bridged by a shared group:
- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process).
- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). Its systemd unit declares `RuntimeDirectory=vacuum-wall nginx` (pre-creates `/run/vacuum-wall` and `/run/nginx` before namespace setup) and `LogsDirectory=vacuum-wall` (`/var/log/vacuum-wall`; the management unit declares the same `LogsDirectory`).
- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`.
- **Shared group**: Both users share the WebUI user's primary group. The daemon socket is owned by `vacuum-walld:<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.
@@ -66,14 +79,14 @@ Vacuum Wall uses JWT-based authentication with access/refresh token rotation. To
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access | 15 min | sessionStorage / memory | API auth, permission checks |
| Access | 5 min on fresh install (config-driven; code fallback 900 s) | sessionStorage / memory | API auth, permission checks |
| Refresh | 7 days | sessionStorage | Token rotation, new access tokens |
JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`.
Every JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), and `type` (`"access"` or `"refresh"`). **Access tokens additionally carry `permissions` (per-subsystem permissions) and `session_id` (session binding — always present; a fresh ID is generated if the caller does not supply one). Refresh tokens carry only `session_id`, and only when a session was bound at login — they never carry `permissions`.** The Flask middleware relies on both: it reads `permissions` from the access token and cross-checks the `X-Session-Id` request header against the token's `session_id` claim.
Each user has a unique signing secret stored in the `users.jwt_secret` database column (generated as a 32-byte base64url token via `secrets.token_urlsafe(32)`). This per-user secret model means tokens signed for one user cannot be validated as another user's tokens. Both Flask and daemon processes validate tokens by extracting `sub` from the unverified payload, looking up the user's secret, and verifying the signature with that secret. Expired and blacklisted tokens are rejected against the SQLite `token_blacklist` table (via `data/auth.db`).
Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. The blacklist is cleaned of expired entries on every refresh operation.
Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. Blacklist cleanup is **probabilistic, not per-refresh**: `blacklist_token()` purges expired entries with a 2% chance on each call, and the daemon's poll loop additionally runs `blacklist_expired()` at most every 60 seconds (coordinated across all poll loops via a shared lock).
## Permission Model
@@ -86,7 +99,7 @@ Flask `before_request` middleware enforces permissions by extracting the subsyst
The `auth` subsystem controls user management. User CRUD endpoints (`/api/auth/users/*`) require `auth: "rw"` ("admin required").
Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`.
Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`. (The SPA root `GET /` is also exempt.) Note that for all *other* API routes the middleware requires **both** the `Authorization: Bearer <token>` and `X-Session-Id` headers — a request with only one of the two is rejected with 401.
## Database Layer
@@ -113,7 +126,7 @@ Environment variables (not config files) control database access:
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
Both Flask (`webui/server.py`) and daemon (`daemon/server.py`) call `get_db()` at startup. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite.
Flask (`webui/server.py`) calls `get_db()` once at startup to open (and initialize) the database. The daemon does **not** call `get_db()` at startup — it reaches the database lazily through `lib.auth` / `lib.auth_users` the first time an auth operation actually runs. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite.
## Install-Time Templating
@@ -134,8 +147,8 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` is a pre-apply recovery snapshot (`{timestamp, default_zone, zones, config}`) written before every apply; `zones` is the permanent firewalld zone view. |
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<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. |
| 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. |
| 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/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. |
#### Background Polling
@@ -149,49 +162,90 @@ The daemon runs background polling tasks for subsystems with external runtime st
| dnsmasq | 10s | Lease file + service status |
| networkd | 10s | Interface up/down, DHCP address changes |
| system | 1s | Real-time metrics (load/memory/swap/traffic) |
| nginx | 60s | Config-file drift self-heal (lazy in-place migration) |
| acme | 300s | Config-file drift self-heal (lazy in-place migration) |
| nginx | 60s | Drift re-collection — the collector is a **pure re-read** of the JSON config and rendered artifacts on disk, so polling re-collects manual edits and out-of-band applies. The one-shot nginx legacy-format migration itself is *not* part of the read path — it runs once at startup via `lib/bootstrap.py` (`nginx.migrate_config_file()`). |
| acme | 300s | Drift re-collection — the collector is a **pure re-read** of acme.sh state. The only self-heal in the ACME path is `normalize_acme_home()` (reopens group access on the acme.sh tree, which acme.sh hardens to owner-only on every run). |
Only `auth` is not polled — it has no external runtime state.
All 7 state subsystems are polled. `auth` is **not** a state subsystem at all — auth data lives in the SQLite DB and is fetched on demand by Flask and the daemon, so it has no poll loop and no WS stream.
**Two-layer diff:** Each poll cycle classifies changes as:
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", "subsystem": ..., "data": ...}` → daemon pushes the full subsystem data over WS; the client patches the model in place via `modelSet`
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump
- **No change**: silence
Volatile fields per subsystem: `system` (load/memory/swap/traffic), `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
Volatile fields per subsystem, defined per collector via `register_volatile()`: `system` (load/memory/swap/traffic), `firewall` (`interfaces[].ips` / `interfaces[].ipv6`), `networkd` (`interfaces[].addresses` only), `wireguard` (peer transfer/handshake stats — both the combined `status.peers[].*` and the per-class `status.classes[].peers[].*`).
Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`, and `broadcast_versions` additionally skips a `None` payload defensively (a null payload would clobber good client data — the next successful poll or mutation broadcasts the real value).
On collector failure **during a poll**, no broadcast is sent (avoids noisy ticks) and the existing state is **kept**`poll()` returns "no change" and does not clear the stored data (only `populate()` clears a subsystem's state to `None` when its collection fails). `broadcast_versions` additionally skips a `None` payload defensively (a null payload would clobber good client data — the next successful poll or mutation broadcasts the real value).
## Apply Bookkeeping and Pending Changes
Every config-backed subsystem records its last-applied state in two keys inside
its declarative JSON: `_last_applied_hash` (SHA-256 of the meta-stripped config)
and `_last_applied_config` (a snapshot of the config at apply time). The helpers
live in `lib/common.py`:
- **`stamp_applied(cfg)`** — writes both keys. Called by each subsystem's apply
handler after a successful apply.
- **`compute_pending(cfg)`** — returns `(pending, diff)`. Pending when the hash
is missing or stale; `diff` is a field-level `deep_diff()` between the
recorded snapshot and the current (meta-stripped) config.
- **`strip_apply_meta(cfg)` / `config_hash(cfg)`** — ignore the bookkeeping keys
when hashing or comparing configs.
- **`revert_to_applied(path)`** — rewrites a config file from its
`_last_applied_config` snapshot (re-stamped so the pending check reports it as
up to date); returns a reason instead when no baseline is recorded (never
applied).
The `status` handler exposes this cross-subsystem:
- **`GET /status/pending`** — aggregates pending changes per subsystem (the
firewall section additionally carries the advisory `uncovered_interfaces`
list).
- **`POST /status/apply-all`** — applies pending subsystems in dependency order
**networkd → firewall → wireguard → dnsmasq → nginx** — calling each
subsystem's apply handler. `{"force": true}` is forwarded only to the
firewall apply, where it overrides the management-lockout and
interface-coverage guards.
- **`POST /status/cancel-all`** — reverts every pending subsystem's config file
to its last-applied snapshot (subsystems with no recorded baseline are
skipped with a reason). Cancel touches only the declarative config files —
it never runs live-system commands.
## System Config Import
On daemon startup, `lib/system_import.py` reconciles live system configurations
with the declarative JSON configs. This ensures that configurations created
by `scripts/install.sh` or edited manually in system files are imported into
the JSON source of truth, preventing drift.
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
to reconcile live system configuration with the declarative JSON configs. This
ensures that configurations created by `scripts/install.sh` or edited manually
in system files are imported into the JSON source of truth, preventing drift.
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
import function:
Each subsystem import function parses the corresponding live system config and
updates the JSON config when they differ:
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
block between comment markers) → `config/dnsmasq/config.json`. Only writes
if config doesn't exist or differs.
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf`
`config/wireguard/config.json`. Skips if configs match.
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
(install-time files) → `config/network/config.json`. Only adds/updates
interfaces; doesn't remove interfaces without a file (they may be pending apply).
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf`
`config/nginx/config.json`. Only touches vacuum-wall-managed files
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
- **`import_networkd`**: Globs **all** `/etc/systemd/network/*.network` files
`config/network/config.json`, stripping any numeric priority prefix from the
filename (`99-eth0.network``eth0`; `eth0.network``eth0`). Only
adds/updates interfaces; doesn't remove interfaces without a file (they may
be pending apply).
- **`import_nginx`**: **Skips entirely if `config/nginx/config.json` already
exists** — it only bootstraps the declarative config from rendered
`data/nginx/sites-enabled/*.conf` (vacuum-wall-managed files identified by
the `# Auto-generated by Vacuum Wall` header; `_acme-challenge.conf` is
skipped) on hosts where the JSON config is absent. When the config exists it
wins: re-parsing generated server blocks is lossy (backend references get
flattened to inline paths).
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones`
`config/firewall/config.json`. Only writes if no config file exists
(firewalld state always takes precedence).
Import failures are silently logged as warnings — they never abort daemon startup.
The returned list of updated subsystems is logged for debugging.
All imports are **idempotent** and **non-destructive**: they only write when
configs differ, skip on failure (logged as warnings), and never abort daemon
startup. The returned list of updated subsystems is logged for debugging.
## Cross-Subsystem Sync Event Bus
@@ -207,16 +261,27 @@ subsystems — no handler calls into another handler's logic directly.
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
for each interface serving a DHCP range. Back-propagates gateway (interface
IP) into DHCP ranges so clients receive their default route.
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
entries when no active peers exist.
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
ranges. Syncs firewall zone interface assignments — adding new interfaces
and removing stale ones no longer in network config.
- **WgToFirewallSync**: Manages **per-access-class** firewall zones: for each
access class with peers, ensures a `vpn-<key>` zone exists with the class's
WireGuard interface (`wg-<key>`), masquerade enabled, and a UDP accept
rich rule on the class's `listen_port` (default 51820). Classes with
`lan_access: true` additionally get inter-zone accept rules for internal
subnets (derived from zones without masquerade); `lan_access: false`
(internet-only) classes get no internal rules. Stale class zones
(`vpn-<key>` whose class no longer has peers) have their WireGuard-created
entries cleaned up. The single `vpn`/51820 zone is managed **only as a
legacy fallback** when peers exist without an `access_class`; when
WireGuard is fully inactive all WireGuard-created entries (interface,
masquerade, `_source: wg` rules) are removed from the legacy zone.
- **FirewallToDhcpSync**: **Never deletes** DHCP ranges. Ranges whose
interface no longer belongs to any zone are kept in config and flagged
inactive (advisory warning). The only mutation is backfilling the
`gateway` (interface IP) on ranges for zones with masquerade enabled.
Zones with the `dhcp` service but no range are logged.
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
ranges (advisory only). New network interfaces are logged/flagged but
**never added** to zones; the only mutation is removing interfaces no
longer present in the network config from the zones that still list them.
4. The handler refreshes state for the originating subsystem plus all
transitively affected subsystems.
@@ -238,27 +303,29 @@ Minimal. The sync happens transparently in the backend. The "pending changes"
indicator on the firewall page will show pending when DHCP or WireGuard saves
(since sync writes JSON but does not call firewall-cmd).
## System Config Import
## Daemon Startup Order
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
to reconcile any drift between system configuration files and the declarative
JSON configs. This is invoked from `daemon/server.py` during initialization.
The daemon's `main()` (`daemon/server.py`) runs a fixed startup sequence after
the aiohttp app is listening on the Unix socket and WebSocket port:
Each subsystem import function parses the corresponding live system config and
updates the JSON config if they differ:
| Subsystem | Source | Condition |
|---|---|---|
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
All imports are **idempotent** and **non-destructive**: they only write when
configs differ, skip on failure (logged as warnings), and never abort daemon
startup. This ensures that manual edits to system files (e.g., during install
or troubleshooting) are reconciled into the declarative JSON source of truth.
1. **`system_import.import_all()`** — reconciles live system configs into the
declarative JSON (see System Config Import above). Runs **first** because it
must see absent config files in order to adopt live system state on first
start.
2. **`bootstrap()`** (`lib/bootstrap.py`) — creates the runtime `config/` +
`data/` directories for all subsystems and persists the one-shot nginx
legacy-format migration (`nginx.migrate_config_file()`). Idempotent. It
deliberately never creates config *files*: `get_config` reads are pure
(missing file → in-memory defaults), so files are materialized on the first
`save_config` (or by the import itself).
3. **`normalize_acme_home()`** — reopens group access on the acme.sh tree
(acme.sh hardens it to owner-only on every run); a failure here is logged,
never fatal.
4. **First `state_store.populate()`** — collects all subsystem state; the
version counter of every successfully populated subsystem is bumped so the
first WS snapshot is followed by a `versions` broadcast.
5. **`start_polling(loop)`** — spawns one background poll task per subsystem
(intervals per Background Polling above).
## Directory Structure
@@ -288,7 +355,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
```
data/
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
├── auth.db # SQLite database: users, permissions, token_blacklist, refresh_tokens, webauthn_creds, init_sequence
├── nginx/
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only)
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
@@ -300,14 +367,14 @@ data/
├── logs/
│ └── vacuum-wall.log # Application log file
└── wireguard/ # WireGuard runtime artifacts
├── networkd/ # Generated 50-<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.
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends).
`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` and `/run/nginx.pid` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon; nginx rewrites the pid file on start). `/run/nginx.pid` is additionally listed in the unit's `ReadWritePaths=`: the daemon's `nginx -t` opens the pid file for *writing*, so a read-only mount would fail every daemon-side `nginx -t` (and therefore `/nginx/apply`) with EROFS — the tmpfiles entry guarantees the file exists at spawn. `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends).
## File System Layout
@@ -318,14 +385,15 @@ The following file system locations are used for integration with system service
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) |
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.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/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) |
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq, wireguard, networkd). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
| `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) |
| `/run/nginx.pid` | nginx pid file. Must exist at spawn **and** be writable by the daemon: its `nginx -t` opens the file for writing, so it needs both a boot-time creator (`system/tmpfiles.d/vacuum-wall.conf`) and a `ReadWritePaths=` entry (nginx rewrites it on start). | nginx / systemd-tmpfiles (early boot) |
| `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) |
| `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) |
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, refresh_tokens, webauthn_creds, init_sequence. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
@@ -337,18 +405,19 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
```
Client requests / ──→ nginx ──→ Flask (serves index.html)
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, auth model 'check' fetch action (GET /api/auth/session; not-ok with a stored refresh token → exactly one refresh) → if no valid session, render #login
Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
apiFetch() ──→ injects BOTH Authorization: Bearer <token> and X-Session-Id headers ──→ Flask REST API
Flask before_request ──→ validates JWT + session ID from headers, checks blacklist, verifies permissions
Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer <token>` subprotocol + X-Auth-Token header fallbacks accepted)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
Token expiry ──→ TTL-driven scheduleRefresh() (timer at access-token TTL 60s, min 30s) ──→ POST /api/auth/refresh ──→ new tokens
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
WS close ×3 ──→ refreshAuth() + reconnect; 2 consecutive failed refresh+reconnect episodes ──→ give up: no more reconnect attempts until the page is reloaded (REST API keeps working)
```
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
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.
### Component Model
@@ -356,12 +425,24 @@ Each route is a `definePage()` component with reactive state, async data loading
### No Build Step
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers: the management domain's `/static/` assets carry `Cache-Control: no-cache` (browsers revalidate every load; unchanged files return 304 via nginx's built-in ETag), so updates are picked up on the next page load. Dev mode (`VACUUM_WALL_DEV`) uses short TTLs instead.
Because there is no build step, backend changes can be hot-reloaded too: the `vacuum-wall` systemd unit defines `ExecReload=` which sends SIGHUP to the Flask process — Flask auto-reloads its `webui.*` and `lib.*` modules and then restarts itself, so `systemctl reload vacuum-wall` picks up code changes without a full stop/start.
### WebSocket Data Streaming
The daemon pushes state over the WebSocket — no HTTP round-trip for auto-refresh. On connect, after the JWT handshake, it sends a full snapshot (`{"type": "snapshot", "data": {subsystem: state|null, …}}`). On every structural change it broadcasts a per-subsystem delta (`{"type": "versions", "subsystem": …, "data": …}`); on volatile-only changes it sends `{"type": "tick", "subsystem": …, "data": …}`. The client's `handleMessage` patches the matching reactive model in place via `modelSet()`, and the VDOM diff touches only the changed nodes. HTTP remains the fallback for the initial load (3s timer) and for reconnect recovery.
## Firewall Interface-Coverage Invariant
A core apply-path guarantee: every network-managed interface (`lo` and `wg*` excluded) must be covered by a zone in `config/firewall/config.json` **or** listed under the top-level `unmanaged` key. The config is the source of truth for zone interfaces — an omitted `interfaces` key counts as an empty list, so there are no hands-off zones.
The check is the pure `lib.firewall.validate_coverage()`, enforced at:
- **Save time**`POST`/`PATCH /firewall/config` returns 400 when the proposed config would leave an interface uncovered.
- **Apply time**`POST /firewall/config/apply` returns 409 on coverage failure; `{"force": true}` (e.g. from the status apply-all endpoint) overrides the guard.
- **Live drift is advisory only** — the firewall state carries an `uncovered_interfaces` field and the status pending summary surfaces it, but live coverage never blocks a save or apply on its own.
## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
+154 -87
View File
@@ -48,7 +48,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| Field | Type | Required | Description |
|---|---|---|---|
| `ranges` | array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: `[]`. |
| `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). |
| `ranges[].interface` | string | No | Network interface on which to serve this DHCP range (e.g., `eth1`). Omit for a global range served on all interfaces (renders an untagged `dhcp-range`). |
| `ranges[].start` | string | Yes | First IP address in the pool. |
| `ranges[].end` | string | Yes | Last IP address in the pool. |
| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `12h`. |
@@ -56,7 +56,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. |
| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: `[]`. |
| `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). |
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. |
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Vacuum Wall does not validate that this is outside the dynamic pool ranges — keep it outside the pool to avoid address conflicts. |
| `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
### DNS Fields
@@ -76,42 +76,15 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
**File**: `config/nginx/config.json`
This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
This file defines named backends (path-based routing definitions), reverse proxy domains that reference those backends, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/`, the include file `/etc/nginx/conf.d/vacuum-wall.conf` (which also defines the `$connection_upgrade` map used for WebSocket pass-through), the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`, and a catch-all ACME challenge site at `data/nginx/sites-enabled/_acme-challenge.conf` (a port-80 `default_server` serving `/.well-known/acme-challenge/` from the `data/acme/www` webroot for domains without a dedicated server block yet).
```json
{
"domains": {
"app.example.com": {
"force_ssl": true,
"cert": "acme",
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
},
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
},
"/api": {
"backend": {
"host": "192.168.2.51",
"port": 3000,
"proto": "http"
},
"auth": null
}
}
},
"mgmt.example.com": {
"force_ssl": true,
"cert": "acme",
"backends": {
"webui": {
"label": "Vacuum Wall WebUI",
"builtin": true,
"_migrated": true,
"paths": {
"/": {
"backend": {
@@ -120,10 +93,7 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"proto": "http"
},
"is_management": true,
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
"auth": null
},
"/ws": {
"backend": {
@@ -134,6 +104,37 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"is_websocket": true
}
}
},
"nas": {
"label": "NAS",
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
}
},
"auth": {
"user": "admin",
"htpasswd": "data/nginx/.htpasswd"
}
}
},
"domains": {
"app.example.com": {
"force_ssl": true,
"cert": "acme",
"backend": "nas"
},
"mgmt.example.com": {
"force_ssl": true,
"cert": "acme",
"backend": "webui"
}
},
"ssl": {
@@ -144,38 +145,58 @@ This file defines reverse proxy domains with path-based routing, and global SSL
}
```
### Domain Entries
### Backends
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends.
The `backends` object maps backend names (keys) to shared routing definitions. Each backend carries the path map and an optional auth block; domains reference a backend by name and serve all of the backend's paths. Paths live on the backend — a domain entry never carries inline `paths`.
| Field | Type | Required | Description |
|---|---|---|---|
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path level. |
| `label` | string | Yes (on create) | Human-readable display name for the backend. Required when adding via `POST /nginx/backends/add`. |
| `paths` | object | Yes | Path-to-config map (schema in [Path Entries](#path-entries) below). |
| `auth` | object | No | Backend-level HTTP basic auth (`{ user, htpasswd }`). Used by any domain referencing this backend unless overridden at the domain level. |
| `builtin` | boolean | No (read-only) | Read-only flag set on the built-in `webui` backend. Builtin backends cannot be modified or removed. |
| `_migrated` | boolean | No (internal) | Internal marker set by the legacy-format migration. Not user-settable; stripped from API responses. |
Backends are managed through the daemon endpoints `GET /nginx/backends` (secrets stripped; each entry reports a `has_auth` boolean instead of the auth object), `PATCH /nginx/backends` (deep-merge partial update; `auth: null` or `auth: false` removes auth), `POST /nginx/backends/add` (creates a new backend; `400` if the name already exists), and `DELETE /nginx/backends/remove` (`400` for builtin backends, `409` when a domain still references the backend).
### Path Entries
Each entry under `paths` defines a location block and its proxy backend.
Each entry in a backend's `paths` map defines an nginx `location` block and its proxy target.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service for this path. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `backend.proto` | string | Yes | Protocol: `http` or `https`. Required — no default; absence is a validation error when adding or updating a backend. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. Not rendered on `is_management` paths. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain/backend-level auth. `null` renders `auth_basic off` for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. The server block gets a `/static/` alias block serving `webui/static/` from disk (with `no-cache` revalidation), uses the dedicated `wall_mgmt_access.log` / `wall_mgmt_error.log` log files, and suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
### Domain Entries
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block and references a shared backend by name — all of that backend's paths are served under the domain.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | string | Yes | Name of the backend (in `backends`) to proxy through (e.g., `"webui"`). Must reference an existing backend. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string \| object | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `cert_path` | string | No | For `cert: "file"`: path to the certificate file. (Also settable as `cert: { "cert_path": ..., "cert_key_path": ... }` in dict form.) |
| `cert_key_path` | string | No | For `cert: "file"`: path to the private key file. |
| `auth` | object \| null | No | Domain-level HTTP basic auth override (`{ user, htpasswd }`). Takes precedence over the referenced backend's `auth`; see [Auth Inheritance Rules](#auth-inheritance-rules). |
### Auth Inheritance Rules
- Domain-level `auth` applies to all paths unless overridden.
Effective auth for a domain is resolved in order: **domain `auth` → referenced backend `auth` → `None`**.
- A domain `auth: { ... }` overrides the referenced backend's auth for that domain; a domain without an `auth` key falls back to the backend's.
- Path-level `auth: null` means "no auth" for that path.
- Path-level `auth: { ... }` overrides domain-level for that path.
- No other domain-level settings inherit — `headers` is path-only.
- Path-level `auth: { ... }` overrides for that path.
- No other settings inherit between backends and domains `headers` is path-only.
**API auth form.** When adding or updating a domain through the API, `auth` may be given as `{ user, pass }`. The daemon writes the password into the `.htpasswd` file (SHA-256 crypt, default `data/nginx/.htpasswd`, or the `htpasswd` path supplied in the auth object) and persists only `{ user, htpasswd }` — the raw password is never stored in the config.
### Path ordering
@@ -188,12 +209,14 @@ The `cert` field is a string that selects the provisioning method:
| Value | Description |
|---|---|
| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at `/.well-known/acme-challenge/`. |
| `file` | Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. |
| `file` | Use a pre-existing certificate and private key from the local file system, via the domain's `cert_path` / `cert_key_path` fields (or the dict form `cert: { "cert_path": ..., "cert_key_path": ... }`). Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. |
### Management Domain
The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key.
The Vacuum Wall admin interface is configured as a regular domain entry under `domains` that references the built-in `webui` backend (`"backend": "webui"`). That backend carries `is_management: true` on the root path (Flask app) and a `/ws` path with `is_websocket: true` for WebSocket pass-through. Because the built-in `webui` backend's root path has `auth: null`, the management path never gets nginx basic auth — management authentication is the Flask-layer JWT (bearer tokens); nginx `auth_basic` would suppress the SPA's Bearer requests. This replaces the legacy inline-`paths` form in which the management domain carried its own root and `/ws` paths (see [Backward Compatibility](#backward-compatibility)).
For a management domain without an explicit `cert` (or with `cert: "selfsigned"`), the apply step auto-generates a self-signed certificate at `data/certs/<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:
@@ -203,9 +226,11 @@ htpasswd -bc data/nginx/.htpasswd admin yourpassword
### Backward Compatibility
Config files using the legacy format are auto-migrated on first load:
- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`.
- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path.
Config files using the legacy format are auto-migrated. The migration runs in-memory on every config read and is persisted to disk one-shot at daemon startup. It performs three steps:
1. Materializes the builtin `webui` backend (marked `_migrated: true`). The daemon handler's migration pass additionally harvests the legacy management domain's root-path auth into `backends.webui.auth`.
2. Rewrites legacy management domains (a root path pointing at `127.0.0.1:9090` with `is_management` and a `/ws` path pointing at `127.0.0.1:9091` with `is_websocket`) to `"backend": "webui"`, deleting their inline `paths` and `auth`.
3. Strips the legacy `application: "webui"` key.
### Global SSL Settings
@@ -235,16 +260,16 @@ This file stores the ACME account settings used by acme.sh for certificate provi
| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | No | Contact email for the ACME account. Used for certificate expiry notifications and recovery. Populated automatically when an account is registered via the WebUI. Default: `""`. |
| `ca` | string | No | ACME CA provider. One of: `"letsencrypt"` (Let's Encrypt), `"zerossl"` (ZeroSSL). Populated automatically when an account is registered. Default: `""`. |
| `ca` | string | No | ACME CA server. Any `server` string — passed through verbatim to `acme.sh --server` (e.g., `letsencrypt`, `zerossl`, or a private/staging CA). Not a closed enum. Populated automatically when an account is registered (the WebUI defaults to `letsencrypt` when no server is given). Default: `""`. |
### Account Registration
ACME account registration is handled entirely through the WebUI. When the user registers an account:
1. The user navigates to the Certificates page and clicks "Register Account".
2. Provides an email address and selects a CA provider (Let's Encrypt or ZeroSSL).
3. The backend calls `acme.sh --register-account` with the provided parameters.
4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its `.account.conf` file under `data/acme/`.
2. Provides an email address and a CA server (defaults to `letsencrypt`).
3. The backend calls `acme.sh --register-account -m <email> --server <ca>`.
4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its account state under `data/acme/` (modern acme.sh v3.x writes `account.conf`, without a leading dot).
Before any certificate can be issued, an ACME account must be registered. The certificate validation flow includes a blocking check (`account_registered`) that prevents issuance if no account exists.
@@ -252,17 +277,21 @@ Before any certificate can be issued, an ACME account must be registered. The ce
After registration, the account can be managed from the WebUI:
- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -u`.
- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -m <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.
### ACME Home Directory
acme.sh stores its state under `data/acme/` (the ACME home directory). Key files:
- `.account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`).
- `<domain>/` — Per-domain certificate and key files issued by acme.sh.
- `account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`). Modern acme.sh (v3.x) writes `account.conf` (no leading dot); older v2.x wrote `.account.conf`, and both names are still recognized.
- `ca/<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
@@ -278,9 +307,8 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
"algorithm": "HS256"
},
"webauthn": {
"rp_name": "Vacuum Wall",
"rp_id": "<management-domain>",
"origin": "https://<management-domain>"
"enabled": true,
"rp_name": "Vacuum Wall"
}
}
```
@@ -289,7 +317,7 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
| Field | Type | Required | Description |
|---|---|---|---|
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). |
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Code fallback default: `900` s; the fresh-install bootstrap writes `300` s (5 min). |
| `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. |
@@ -297,15 +325,18 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
### WebAuthn Fields
The WebAuthn config block holds only two fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. |
| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. |
| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://<rp_id>`. |
| `enabled` | boolean | No | Whether WebAuthn is enabled. Default: `true`. |
| `rp_name` | string | No | Display name for the WebAuthn Relying Party. Shown during credential registration. Default: `"Vacuum Wall"`. |
`rp_id` and `origin` are **not** config fields. They are derived per-request from the management domain the request arrives on and validated against the live management domains (the WebAuthn endpoints refuse domains that do not serve the management UI).
## Database Schema
The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`.
The SQLite database at `data/auth.db` stores authentication data across six tables. Created automatically on first access via `get_db()`.
### users
@@ -336,7 +367,17 @@ UNIQUE constraint on `(username, subsystem)`.
| `token_type` | TEXT | `"access"` or `"refresh"` |
| `expires` | INTEGER | Unix timestamp of token expiry |
Used to invalidate tokens on logout and password change. Expired entries are cleaned on every refresh operation.
Used to invalidate tokens on logout and password change. Expired entries are cleaned up by the daemon's periodic poll loop (at most every 60 seconds) and probabilistically (roughly 2% of the time) inside `blacklist_token()` — not on every refresh.
### refresh_tokens
| Column | Type | Description |
|---|---|---|
| `username` | TEXT | Primary key (unique) — the owning user |
| `jti` | TEXT | JWT unique identifier of the current refresh token |
| `issued_at` | INTEGER | Unix timestamp when the refresh token was issued |
At most one active refresh session per user: the `username` column is unique, so issuing a new refresh token replaces the stored entry for that user. The active refresh token is blacklisted and removed on logout and password change.
### webauthn_creds
@@ -352,6 +393,12 @@ Used to invalidate tokens on logout and password change. Expired entries are cle
UNIQUE constraint on `(username, credential_id)`.
### init_sequence
| Column | Type | Description |
|---|---|---|
| `seq` | INTEGER | Primary key — bookkeeping sequence marker |
## WireGuard Configuration
**File**: `config/wireguard/config.json`
@@ -422,11 +469,13 @@ This file defines the WireGuard server interface, access classes, and all connec
### Access Classes
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<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 |
|---|---|---|---|
| `name` | string | Yes | Human-readable display name for the class. |
| `name` | string | No | Human-readable display name for the class. Defaults to the class key when omitted. |
| `description` | string | No | Optional description of what access level this class provides. Default: `""`. |
| `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. |
@@ -460,12 +509,14 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
| `description` | string | No | Optional description for the peer. Default: `""`. |
| `description` | string | No | Optional description for the peer. The API defaults it to `""` when a peer is added via the endpoint; the lib-level `add_peer()` stores `null` when the field is omitted. |
| `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. |
### Client Configuration Generation
When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. `generate_client_conf()` computes the client IP address from the server's subnet and the peer's sorted index position.
When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API.
`generate_client_conf()` derives the client IP address and the `Endpoint` port from the peer's **access class** when the peer is class-assigned — the class's `subnet` and `listen_port` are used, not the server interface's. For unassigned peers it falls back to the server interface's `addresses[0]` and `listen_port`. The client's host index is the peer's position in the sorted list of **all** peer keys (across every class) plus 2 (index 1 is reserved for the server).
### Applying Configuration
@@ -507,17 +558,25 @@ This file defines the declarative firewalld zone configuration. The application
}
]
}
}
},
"unmanaged": ["eth9"]
}
```
### Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `zones` | object | Yes | Zone name → zone configuration (below). |
| `unmanaged` | array | No | Network interfaces that are deliberately **not** covered by any zone. Exempts them from the [interface-coverage invariant](#interface-coverage-invariant). Default: `[]`. |
### Zone Fields
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`.
| Field | Type | Required | Description |
|---|---|---|---|
| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. |
| `interfaces` | array | No | Network interfaces assigned to this zone. The config is the source of truth: an omitted key counts as an empty list (apply unassigns the zone's live interfaces). Default: `[]`. |
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. |
| `target` | string | No | Zone target policy. `ACCEPT`, `DROP`, or `REJECT` is fully managed. When the key is **omitted** (the canonical "unmanaged" notation) or normalizes to `default` (e.g. a legacy explicit `"DEFAULT"`), the live value is **preserved** — it is not diffed and never re-set by apply (firewalld cannot set `default` back). |
| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. |
@@ -528,25 +587,33 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
| `forward_ports[].toaddr` | string | No | Internal IP address to forward to. Omit for broadcast forwarding. |
| `forward_ports[].toport` | integer | No | Internal port to forward to. Omit to keep the same port. |
| `rich_rules` | array | No | Rich rule entries for advanced firewall policies. Default: `[]`. |
| `rich_rules[].id` | string | No | Auto-generated unique identifier (8-hex UUID) for the rich rule. Not user-settable; assigned when the rule is added via the API. The `DELETE /firewall/rich-rules/remove` endpoint addresses rules by this `id`. |
| `rich_rules[].rule` | string | Yes | The full firewalld rich rule string, e.g., `rule family="ipv4" source address="10.0.0.0/8" reject`. |
### Applying Firewall Configuration
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. Likewise the `interfaces` entry is only reported for zones whose config explicitly carries the key.
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.
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
**Interface-coverage guard.** `POST /firewall/config/apply` also refuses (before any mutation) if applying would leave a network-subsystem-managed interface in **no** firewall zone — traffic (and DHCP) on that segment would be dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Zones whose config omits the `interfaces` key are left hands-off, so their current live interfaces count as coverage, as do the live interfaces of zones that are live but absent from the config. Send `"force": true` to override. The condition is always surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`) and as an advisory in `GET /api/status/pending`.
**Interface-coverage invariant.** Every network-subsystem-managed interface must be covered by a zone in the firewall config — otherwise all traffic (and DHCP) from that segment is dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Because the config is the source of truth for zone interfaces (an omitted `interfaces` key counts as empty), coverage is computed from the config **alone** via `validate_coverage()` — there is no live-state fallback and no hands-off zones. Interfaces listed in the top-level `unmanaged` key are exempt. The invariant is enforced at two points:
- **Save time**`POST /firewall/config` and `PATCH /firewall/config` reject a config that leaves a managed interface uncovered with `400 Bad Request`, before anything is written.
- **Apply time**`POST /firewall/config/apply` re-checks the (possibly stale) saved config against the current network config and raises `409 Conflict` before any mutation. A conflict here means the network config changed after the firewall config was saved (e.g. a new interface no zone covers).
Send `"force": true` in the request body to override the apply-time check (the UI offers this via the Apply dialog). Live drift — an interface that is covered by the config but not in any **live** zone — is advisory only: it is surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`), the Zones-page banner, and an advisory in `GET /api/status/pending`, and is never blocked by the invariant.
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
**Public-zone masquerade propagation.** With firewalld's nftables backend, traffic leaving through the public zone hits the public zone's POSTROUTING chain, so NAT only works if the public zone itself has masquerade enabled. During apply, if any non-public zone has masquerade enabled but the public zone does not, apply propagates masquerade to the public zone (and writes it back into the config); conversely, when no non-public zone needs masquerade, apply removes it from the public zone. Consistently, the `POST /firewall/masquerade` endpoint **refuses** to enable masquerade on the `public` zone directly (enable it on `internal` or a `vpn` zone instead — the API returns an error directing you there).
## Networkd (IP Configuration)
**File**: `config/network/config.json`
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-<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
{
@@ -596,7 +663,7 @@ Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`,
| `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. |
| `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. |
| `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. |
| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. |
| `dhcp_client` | `object` | DHCP client settings. `[DHCPv4]` and `[DHCPv6]` have **different** key sets (which sections render is controlled by `dhcp`). Shared by both: `hostname`, `duid`, `duid_type`, `duid_raw_data`, `iaid`, `anonymize`, `rapid_commit`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_hostname`, `use_domains`, `net_label`, `nft_set`, `send_option`, `send_vendor_option`, `user_class`. IPv4-only (`[DHCPv4]`): `client_identifier`, `use_mtu`, `use_routes`, `route_metric`, `send_decline`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `vendor_class_identifier`, `request_options`. IPv6-only (`[DHCPv6]`): `send_hostname`, `prefix_delegation_hint`, `unassigned_subnet_policy`, `use_address`, `use_delegated_prefix`, `use_dnr`, `send_release`, `without_ra`, `vendor_class` (a list; each entry renders a `VendorClass=` line). |
| `bind_carrier` | `array` | Carrier interfaces to bind to. |
| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. |
| `keep_configuration` | `boolean` | Keep configuration on stop. |
@@ -630,7 +697,7 @@ When `POST /api/network/apply` is called, the handler automatically collects pub
### 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
@@ -641,7 +708,7 @@ are updated automatically through the event bus.
|---|---|---|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
| wireguard (peer add/remove) | firewall | Per-class `vpn-<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) | 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 |
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Auto-detected from the system hostname; defaults to `$(hostname -f \|\| hostname).local` (FQDN first, falling back to the short hostname; mDNS-served on the LAN). **Errors if the hostname is undetectable and this is not set.** |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for the initial admin user (default: `admin`). Creates the admin user in the SQLite database with full `rw` permissions on all subsystems. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
@@ -64,9 +64,9 @@ The `--dev` flag is designed for developers working in a git clone. It auto-dete
In dev mode, the ownership model preserves the developer's ability to work with the repository:
- **Project directory**: Owned by the repo owner (e.g., `wall`), group is the repo owner's primary group (e.g., `wall`). The developer retains full control — `git add`, `git commit`, editing code and config files all work normally.
- **Daemon access**: The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group.
- **Daemon access**: The daemon user (`walld`, i.e. `${USER_NAME}d`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group.
- **`.venv/` and `data/`**: Owned by the repo owner, group is the repo owner's primary group. The developer can run `pip install`, inspect logs, and manage runtime artifacts. The daemon reads `.venv/` (Python interpreter) and writes to `data/` (runtime files) via group permissions.
- **Daemon socket** (`data/daemon.sock`): Owned by `vacuum-walld:<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
@@ -74,7 +74,7 @@ In dev mode, the ownership model preserves the developer's ability to work with
./scripts/install.sh --dev --mgmt-pass strongpassword
```
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
The script detects the repo owner (e.g., `wall`), creates the `walld` daemon user (`${USER_NAME}d`) with the repo owner's primary group, and sets up the ownership model described above.
### Idempotent Re-Runs
@@ -92,7 +92,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o
--mgmt-domain proxy.internal --mgmt-pass strongpassword
```
The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `INSTALL_DIR`. This means no hardcoded paths remain after installation.
The systemd `.service` unit files and the sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME`, `USER_DAEMON_NAME`, `USER_GROUP`, `PROJECT_DIR`, and `ACME_HOME`. This means no hardcoded paths remain after installation.
---
@@ -103,29 +103,35 @@ The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils.
- **WebUI user creation**: Creates the WebUI user (from `--user`) as a system user if it does not exist.
- **Shared group**: Uses the WebUI user's primary group as the shared group between both service users.
- **Daemon user creation**: Creates `vacuum-walld` (derived from WebUI user name) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the project directory and daemon socket.
- **Daemon user creation**: Creates the daemon user `${USER_NAME}d` (the literal `vacuum-walld` only when the WebUI user is `vacuum-wall`) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the daemon socket and, outside `--dev` mode, the project directory (in dev mode the repo owner keeps project ownership).
- **Python venv**: Creates the Python virtual environment and installs project dependencies. Skips if already present (use `--force-venv` to recreate).
- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
- **acme.sh installation**: Fetches the vendored acme.sh (via `scripts/update-vendor.sh`) and installs it to `data/acme/acme.sh`, skipping if it is already present. Also installs the `system/acme-deploy.sh` deploy hook into `data/acme/deploy/acme-deploy.sh` (acme.sh only resolves hooks from its own deploy directory) and repairs ownership of the acme.sh runtime conf files under `data/acme/` — including `account.conf`, which is chmodded to `0640` — to the daemon user, so the first acme.sh run cannot fail on owner-only files.
- **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config).
- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`.
- **System-directory ownership repair**: Checks top-level system directories (`/`, `/bin`, `/boot`, `/etc`, `/home`, `/opt`, `/root`, `/srv`, `/usr`, `/var`, …) for non-root ownership — some appliance images ship with system paths owned by a regular user, which trips systemd-tmpfiles' "unsafe path transition" check. Mis-owned top-level directories are chown'd to `root:root`; if deeper mis-ownership is detected, the installer warns with a full-repair command to run before re-running.
- **Static-asset permissions**: `chmod a+rX` on `webui/static/` (plus `a+x` up the parent directory chain) so nginx's `www-data` workers can serve the management UI's static assets directly from disk, regardless of checkout umask.
- **Template rendering**: Renders the systemd `.service` files and the sudoers whitelist via Jinja2, substituting `USER_NAME`, `USER_DAEMON_NAME`, `USER_GROUP`, `PROJECT_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed: firewalld management (`firewall-cmd`), nginx (config test/reload, copying/removing the generated conf files), dnsmasq (restart, lease-file reads, fragment install), WireGuard (`wg`, `wg-quick`, installing `wg0.conf`), network interface queries (`ip -o link/addr show`), systemd-networkd (`networkctl` status/reload/reconfigure, managing `/etc/systemd/network`), sysctl writes, group-permission repair on the ACME home, and journal/log reads (`journalctl`, `cat /var/log/nginx/*`). Validates syntax with `visudo -cf`.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present.
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
- **Admin user**: Creates the admin user with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`). The user gets `rw` permissions on all subsystems. On re-run, updates the admin password if already present.
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain via `POST /acme/self-signed` (CN set to the domain), written to `data/certs/<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**: Registers the management domain via `POST /nginx/domains/update` (falling back to `POST /nginx/domains/add`) as the special built-in `webui` backend entry (cert `selfsigned`, forced SSL). The `/` → 127.0.0.1:9090 (Flask) and `/ws` → 127.0.0.1:9091 (daemon WebSocket) mapping is derived by the daemon from the built-in webui backend — it is not passed as paths config. Then applies nginx via `POST /nginx/apply`.
- **Admin user**: Creates the admin user (default username `admin`) with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`), with `rw` permissions on all subsystems, and writes `config/auth/config.json` (JWT + WebAuthn settings) if missing. On re-run, updates the admin password if already present. The bootstrap runs with `VACUUM_WALL_SEED_BUILTIN_ADMIN=0`, suppressing the last-resort builtin admin seed so exactly one account exists on a fresh install.
- **Initial configs**: Once the daemon socket is up, the installer writes initial state over the daemon API: `POST /acme/self-signed` (management cert), the management domain plus `POST /nginx/apply`, and firewall zone assignment — `POST /firewall/zones/interfaces` (WAN interface → `public`) and `POST /firewall/zones/services` (http/https/ssh on `public`) when a WAN interface was detected, and `POST /firewall/zones/interfaces` (LAN interfaces → `internal`) when LAN interfaces were detected. These writes are not skipped; the only skip-if-exists rule applies to the auth config (see **Admin user**).
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
- **Systemd units**: Installs four units — three `.service` files are rendered from Jinja2 templates; the `.timer` is installed verbatim:
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
- `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
- **Firewalld zones**: Creates initial zones:
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
- `vpn` — WireGuard tunnel zone.
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals (no template variables).
A fifth file, `system/tmpfiles.d/vacuum-wall.conf`, is installed to `/etc/tmpfiles.d/vacuum-wall.conf` and `systemd-tmpfiles --create` is run immediately — load-bearing for the hardened unit: it provisions the volatile `/run` entries the daemon needs before `vacuum-walld` spawns (restored at every boot by `systemd-tmpfiles-setup.service`).
- **Firewalld zones**: Assigns initial zones via the daemon API (the installer does not create zones directly):
- `public` — the WAN interface is assigned here and the `http`, `https`, and `ssh` services are opened for management access (only when a WAN interface was detected).
- `internal` — the LAN interfaces are assigned here (only when LAN interfaces were detected); no services are added at install time.
- `vpn`**not** created by the installer. It is managed dynamically by `lib/sync.py` only while WireGuard peers exist (interface assignment, masquerade, and rich rules), and is cleaned up again when WireGuard is deactivated.
- **Legacy nginx config cleanup**: Removes the old nginx bootstrap configs (`/etc/nginx/conf.d/vacuum-wall-map.conf` and `/etc/nginx/conf.d/vacuum-wall-mgmt.conf`), which are replaced by the daemon-generated nginx configuration.
- **Service startup**: Enables and starts/restarts nginx, the daemon (`vacuum-walld`), the WebUI (`vacuum-wall`), and the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes.
- **ACME account**: No account registration during install. Register the account via the WebUI after first login.
@@ -136,7 +142,7 @@ The installer performs the following steps automatically:
- Skips the Python venv (use `--force-venv` to rebuild)
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
- Preserves existing `config.json` files (skips initial write if file exists)
- Preserves the existing auth config (`config/auth/config.json` is only written if missing — the only skip-if-exists config rule)
- Updates admin user password if changed
This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
@@ -171,6 +177,13 @@ Log in with the username and password you provided during installation.
|---|---|---|
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
| `VACUUM_WALLD_SOCKET` | `data/daemon.sock` | Daemon Unix socket path (`daemon/server.py:669`) |
| `VACUUM_WALLD_WS_PORT` | `9091` | Daemon WebSocket port on `127.0.0.1` for real-time state streaming (`daemon/server.py:31`) |
| `VACUUM_WALL_POLL_INTERVALS` | built-in per-subsystem defaults | Comma-separated `subsystem:seconds` overrides for the state-poll intervals, e.g. `firewall:60,wireguard:5`; non-integer or ≤ 0 values are skipped with a warning (`daemon/server.py:34`) |
| `VACUUM_WALL_DEV` | unset (off) | Dev-mode flag: disables aggressive static-asset caching in the WebUI (`webui/server.py:86`) |
| `VACUUM_WALL_LOG_LEVEL` | `INFO` | Log level for the WebUI and daemon processes (`lib/logging.py:49`) |
| `VACUUM_WALL_EXTERNAL_IP_URL` | built-in detection | Custom URL for external-IP detection used by ACME (`daemon/handlers/acme.py:285`) |
| `VACUUM_WALL_SEED_BUILTIN_ADMIN` | `1` | Set to `0` to skip the last-resort builtin admin seed in `get_db()`; `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself (`lib/db.py:307`) |
### Post-Deploy Verification
@@ -297,7 +310,9 @@ journalctl -u vacuum-wall --no-pager -n 50
nginx -t
```
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `data/`.
Both units also keep journal output on disk under `/var/log/vacuum-wall/` (`LogsDirectory=vacuum-wall` on both units). Nginx writes per-domain access/error logs to `/var/log/nginx/wall_mgmt_*.log` for the management domain and `/var/log/nginx/<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
@@ -369,16 +384,17 @@ If the SQLite database becomes corrupted:
1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld`
2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"`
3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db`
4. Start services: `sudo systemctl start vacuum-walld vacuum-wall`
3. If the file is unrecoverable, delete it (`rm data/auth.db`) and start the services: `sudo systemctl start vacuum-walld vacuum-wall`. The schema is recreated on startup; if the users table is empty, the last-resort builtin admin is seeded with a random password written to `/var/log/vacuum-wall/auth.log`.
4. Re-set the password via the WebUI, or use the SQLite steps under "Locked Out of WebUI".
### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`.
3. Check the management proxy domain configuration via the WebUI Proxy tab, or by inspecting `config/nginx/config.json`.
4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
4. Ensure the daemon is running and its Unix socket exists: `systemctl status vacuum-walld` and `ls -l data/daemon.sock` — the WebUI proxies every API call through this socket.
5. Ensure the WebUI service is listening on port 9090 (`ss -tlnp | grep 9090`) and the daemon's WebSocket endpoint on port 9091 (`ss -tlnp | grep 9091`).
6. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
---
+470 -106
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 |
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
| HTM | `html.js` | `htm` binding of `vdom.js`'s `htmAdapter` — the `html` tagged-template tag |
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
| WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) |
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting |
| Schema | `schema.js` | Per-subsystem state defaults (`SUBSYSTEMS`) and client-side poll cadence (`POLL_INTERVALS`) |
| Dirty markers | `dirty.js` | Pending-edit (not-yet-applied) UI markers: hash-subsystem and firewall variants |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts, auth ceremony, QR |
| Barrel | `index.js` | Single import point for all public APIs |
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point.
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from
this single entry point, with two exceptions: `pages/certs.js` and `pages/backends.js`
also import directly from `hoover/components/modal.js` (`isModalProcessing`,
`setModalProcessing`, `refreshModals`) and `pages/backends.js` imports `_deleting` from
`hoover/components/data.js`.
## Architecture
@@ -42,11 +49,11 @@ Each render root registers a render function via `render(container, fn)`. When r
```
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (one-shot 3s initial-load timer) → modelFetch(name) → model.data = apiFetch()
```
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
The **model layer** is the single source of truth for subsystem data. Model-backed pages call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. (Two pages — `users.js` and `passkeys.js — fetch page-local data with `apiFetch` in `load()` against a module-level reactive state instead of a registered model; see **Module-level shared reactive state** below.)
State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`).
@@ -57,12 +64,18 @@ Mutations no longer trigger explicit model refreshes: after a successful write t
The app starts from `webui/static/app.js`:
```javascript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch,
modelRegister, modelFetch, getModel, reactive, createAuthModel,
isAuthenticated, getAuthData } from '/static/hoover/index.js';
import { SUBSYSTEMS } from '/static/hoover/schema.js';
// 1. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
// 1a. Auth model — registered first. Silent topic: the daemon never
// broadcasts 'auth', so refreshByTopic() can never fetch it.
modelRegister('auth', createAuthModel());
// 1b. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
@@ -89,14 +102,11 @@ for (const { name, subsystem } of STATE_MODELS) {
});
}
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } });
```javascript
// ... more modelRegister calls ...
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } });
// 2. Initial data. State-backed models receive their first data via the WS
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
// Non-state models fetch immediately.
// snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP)
// if it hasn't arrived. Non-state models fetch immediately.
function fetchInitialData() {
for (const { name } of STATE_MODELS) {
setTimeout(() => {
@@ -108,29 +118,59 @@ function fetchInitialData() {
modelFetch('logs', 'journal');
}
// 3. Create reactive router state
// 3. Custom router — reactive path state plus the auth guard (see Router below)
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
return hComp(page, path);
},
};
// 4. Listen for hash changes
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
// 4. Init: session check before mounting, listeners, conditional boot
export async function initApp() {
// auth:login — (deferred to a macrotask so the login form's hashchange
// has landed) give the post-login session its WS and fetch all models.
window.addEventListener('auth:login', () => {
setTimeout(() => {
connect();
if (!router.state.path.startsWith('/login')) fetchInitialData();
}, 0);
});
// auth:logout (terminal transition) — tear down the WS socket.
window.addEventListener('auth:logout', () => disconnect());
// 5. Mount render roots
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
// Check the session BEFORE mounting the shell: an unauthenticated
// visitor must never flash the sidebar or a protected page.
await modelFetch('auth', { action: 'check' });
authChecked = true;
if (isAuthenticated()) {
if (router.state.path === '/login') window.location.hash = '/dashboard';
fetchInitialData();
setTimeout(connect, 0); // WS only for authenticated sessions
} else if (router.state.path !== '/login') {
window.location.hash = '/login';
}
// 6. Start WebSocket (deferred to avoid initial render conflict)
setTimeout(connect, 0);
// Mount render roots (Sidebar renders null when unauthenticated)
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
}
```
Bootstrap order matters: the auth model is registered first, then the
bootstrap session check (`modelFetch('auth', { action: 'check' })`) is
**awaited before the render roots mount** so an unauthenticated visitor is
redirected to `#/login` before first paint. `connect()` is conditional —
it runs only for an authenticated session (also from the `auth:login`
listener after a fresh login). `disconnect()` is wired to the terminal
`auth:logout` event (see **Auth model**).
## Reactivity
### `reactive(obj)`
@@ -147,7 +187,7 @@ state.data = result;
Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.
**Important:** Hoover's reactivity proxy intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. Always mutate top-level properties by assignment:
**Important:** Hoover's reactivity proxy tracks property **assignment only** (the Proxy `set` trap). Adding a new top-level property is an assignment, so it *does* trigger a re-render. Deletions (`delete state.x`) are **not** tracked — there is no `deleteProperty` trap — and neither are array mutations (`push`, `splice`) or nested object changes (nested objects are plain, not wrapped). Always mutate top-level properties by assignment:
```javascript
// Correct — assigns a new array
@@ -231,9 +271,9 @@ render(state) {
}
```
### `modelFetch(name, signal?, param?)`
### `modelFetch(name, signalOrParam, signal)`
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. The **second argument is the param** (e.g., a tab key or the auth model's `{ action }` object); an `AbortSignal` is accepted there for backward compatibility, and a param-carrying call passes the signal as the **third** argument (`modelFetch('logs', 'journal')`, `modelFetch('auth', { action: 'refresh' })`).
```javascript
// HTTP fallback for a state-backed model (WS snapshot is the primary path;
@@ -256,7 +296,7 @@ modelFetch('logs', 'nginx-access');
**Behavior:**
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
- Sets `model.loading = true` when the model is still in its initial state (`loading` set and `data === null`), otherwise `model.refreshing = true`.
- Clears `model.error` before fetch.
- On success, assigns result to `model.data`.
- On failure, stores error in `model.error`.
@@ -285,11 +325,12 @@ modelSet('firewall', payload); // payload: the subsystem state object
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
**WS Data Streaming Flow** below.
### `refreshByTopic(topic)`
### `refreshByTopic(topic)` — internal, not exported from the barrel
Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via
`modelSet` instead).
Refresh all models whose subsystem topic matches via `modelFetch()`.
**Not re-exported from `hoover/index.js` and never called anywhere** —
`websocket.js` delivers data via `modelSet` instead. It exists in `model.js`
only as an internal / legacy utility; do not rely on it.
| Model `subsystem` | Topic | Match? |
|---|---|---|
@@ -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
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),
session validation, login/logout transitions, and WS reconnection coordination.
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL 60s
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()`
(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' })
→ 200: stores verified user/permissions + stored tokens → schedules refresh
→ 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same
success or terminal path
→ 200: stores verified user/permissions + stored tokens → schedules the
refresh at the token's REMAINING lifetime (exp claim, not the full issued
TTL) minus 60s (minimum 30s)
→ non-2xx response (e.g. 401) with a stored refresh token (stale access
token after page reload/restore): exactly one refresh attempt, then the
same success or terminal path
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
(no auth:login dispatch)
timer fires (TTL 60s) → refreshAuth() → same path
timer fires (remaining TTL 60s, min 30s)
→ modelFetch('auth', { action: 'refresh' }) under the module-level
`_refreshing` guard (skipped if one is already in flight) → same path
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
login → modelFetch('auth', { action: 'login', payload: data })
→ onSuccess stores + schedules + fires auth:login (login action only)
@@ -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
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
(exactly one refresh when the stored access token is rejected at page load while a
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback
(exactly one refresh when the session check gets a non-OK response at page load while a
refresh token is still present).
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
@@ -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`
(would cycle) — the event inverts the dependency.
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any
post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both**
`Authorization` and `X-Session-Id` from `getAuthData()`.
post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must
re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. The WS handshake
is different: it sends **only the token** as the `Sec-WebSocket-Protocol` subprotocol —
`X-Session-Id` is an HTTP-only header and plays no part in the socket handshake.
- **Concurrent refresh guard**`modelFetch`'s in-flight dedup (distinct key per param object:
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
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
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).
@@ -399,11 +457,20 @@ h('div', { class: 'card' }, h('span', null, 'Hello'))
// Text node
h('#text', 'some text')
// Component (Hoover component, not function — must use hComp or h('#comp', ...))
// Function component — `h()` calls the function directly with the props
// (children merged into `props.children`): the function's return value
// (a VNode) is the result. All the UI components (Badge, Card, …) are
// used this way.
h(Badge, { text: 'OK', variant: 'success' })
// Lifecycle component (page) — opaque #comp vnode, NOT called by h():
// managed by the render engine's mount/unmount lifecycle
h('#comp', { component: MyPage, key: '/dashboard' }, [])
```
**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
The `html` tagged-template adapter uses the same function-component path: `<${Badge} … />` compiles to `htmAdapter(Badge, props, …children)`, which forwards to `h()`.
**Children flattening:** children are flattened recursively (`arr.flat(Infinity)` — nested arrays are inlined). `null`, `undefined`, and **all booleans (including `true`)** children are filtered out. String and number primitives are automatically converted to text VNodes.
### HTM (Tagged HTML Templates)
@@ -469,15 +536,16 @@ html`<${Badge} ...${badgeProps} />`
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
| `disabled` | Sets `.disabled` boolean property on applicable elements |
| `selected` | On `<option>`: sets `.selected` |
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
| `key` | Used by keyed diff algorithm; 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
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:
@@ -500,7 +568,7 @@ function 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
@@ -510,6 +578,9 @@ Define a page component with reactive state and rendering. Pages access data thr
```javascript
export default definePage({
// Browser tab title — applied to document.title on mount
title: 'Zones - Vacuum Wall',
// Return initial state — models are obtained via getModel()
init() {
return {
@@ -517,9 +588,10 @@ export default definePage({
};
},
// Optional: one-time setup on mount (e.g., opening a modal dialog)
// Not used for data loading — model layer handles that
async load(state) {
// Optional: one-time setup on mount. Receives (state, abortController) —
// use the controller's signal for any page-local fetches. Not used for
// data loading on model-backed pages — the model layer handles that.
async load(state, abortController) {
// Rarely needed
},
@@ -528,34 +600,39 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
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 [
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
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
| 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. |
| `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`. |
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
### 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.
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.
@@ -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).
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
// Router pattern — key is the path so navigation to a different page unmounts the old one
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
### 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
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
return hComp(page, path);
},
};
// 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', () => {
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.
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)`
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
Link({ path: '/zones', class: 'active', children: ['Zones'] })
// 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
@@ -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).
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()`
@@ -670,14 +826,24 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
- Automatically sets `Accept: 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.
- 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`.
- On non-2xx, returns `{ ok: false, error: "message", status }`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
- **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 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`.
- 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.
### `toast(message, type, duration)`
Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `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.
Toast behavior:
- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it.
- The auto-dismiss timer pauses while the pointer is over the toast.
- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block.
### `dismissToast(id)`
@@ -708,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:**
@@ -717,8 +883,9 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` function, or `undefined` for no body |
| `validate` | `(body) => string | null` — validation function |
| `successMsg` | Success toast message |
| `validate` | `(body) => string \| null` — validation function; errors are toasted |
| `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()`) |
| `submitText` | Submit button text (default: `'Submit'`) |
@@ -726,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
> 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)`
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
@@ -743,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.
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
import { refactorLoad } from '/static/hoover/index.js';
@@ -770,8 +962,8 @@ async function load(state, abortController, entry) {
|---|---|
| `state` | Page state object |
| `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 |
| `opts.entry` | Router entry with `requestId` for staleness checks |
| `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` | 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 |
### `poll(opts)`
@@ -807,7 +999,7 @@ poll({
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
| `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
@@ -912,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.
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
@@ -947,9 +1139,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card.
#### `Card({ header, children })`
#### `Card({ header, children, cls, title, key })`
Card container with optional header.
Card container with optional header. `cls` appends a class to the outer
`div.card`; `title` sets a tooltip on the outer div; `key` sets the VNode key.
#### `ConfirmDelete(props)`
@@ -983,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`.
**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
ActionButton({
url: '/api/dhcp/apply',
@@ -1013,7 +1208,7 @@ ActionButton({
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `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 |
| `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message |
@@ -1152,9 +1347,9 @@ ZoneSelect({
| `onChange` | `(zone) => void` callback |
| `placeholder` | Placeholder option text (optional) |
#### `Table({ columns, rows, emptyText, wrapCard, key })`
#### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes.
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
```javascript
Table({
@@ -1179,23 +1374,48 @@ shared expandable-subsystems modal. Both fetch `/api/status/pending` to
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
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)`
Button that opens the confirmation modal listing pending subsystems, then
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a
disabled "synced" button that toasts on click.
POSTs `/api/status/apply-all`. When `props.pending` is false it renders an
enabled **"synced" button** (not disabled) that toasts
`successMsg || 'All synced'` (type `'info'`) on click.
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`,
`successMsg`, `refresh` (legacy, ignored).
**Force apply:** when the firewall has pending changes (the only subsystem
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)`
Button that opens the confirmation modal listing the subsystems that
would be reverted ("Restores the listed subsystems to their last applied
configuration, discarding changes saved since the last apply"), then
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map; errors from the
response are toasted separately. State-store models update from the
POSTs `/api/status/cancel-all`. The success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map — in that case it is
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`.
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
@@ -1207,15 +1427,33 @@ CancelConfirm({ cls: 'btn btn-sm btn-danger' })
### Modal
#### `openModal(renderFn)`
#### `openModal(renderFn | vnodes)`
Open a modal dialog. `renderFn` receives the modal content element:
Open a modal dialog. Two forms:
```javascript
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});
```
- **renderFn**`renderFn(contentEl, idx) => void`; the second argument is the
modal's queue index. Modals render directly into `#modal-root` via DOM
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])`
@@ -1225,6 +1463,19 @@ Close a modal. Without argument, closes the topmost modal.
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)`
Render a standard modal form inside the modal content element.
@@ -1233,22 +1484,37 @@ Render a standard modal form inside the modal content element.
```javascript
{ 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: 'Enabled', id: 'enabled', type: 'checkbox', checked: true }
{ label: 'Tags', id: 'tags', tag: 'select', multiple: true, options: [...] }
```
- `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
**Action shape:**
```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() }
```
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)`
@@ -1283,10 +1549,11 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
| `submit.method` | HTTP method (default: `'POST'`) |
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
| `submit.validate` | `(body) => string \| null`, validation function |
| `submit.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. |
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
| `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)`
@@ -1331,12 +1598,94 @@ 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)
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
#### `ToastContainer()`
Render the toast notification container. Include in the main render root. See API section above.
## Dirty / pending-edit markers
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
applied to the live system. It consumes the pending state the daemon already
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
#### `PendingDot()`
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
the first cell of a dirty row, or next to a card/section heading.
### Hash subsystems (field-level)
Pending source: `status.pending_diff``[{path, action, old, new}]` where `path`
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
`domains.example.local.cert`).
| Function | Description |
|---|---|
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
| `dirtyInfo(set, path)` | `{dirty, class, title}``class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
parent-like row — never a false negative.
### Firewall (zone + type)
Pending source: `pending``{needs_apply, pending: [{zone, type, ...}]}` where
`type``interfaces|services|target|masquerade|rich_rules|forward_ports`
(zone-level, not field-level).
| Function | Description |
|---|---|
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
### Wiring conventions
- Compute the set **once** per `render()`, after the guard:
`const set = dirtySet(state.<subsystem>.data?.status)` or
`const fw = fwDirty(state.firewall.data?.pending)`.
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
drop `PendingDot({})` into the first cell when `info.dirty`.
- Container elements (tables/sections) whose children are dict keys: pass
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
which leave no row to mark — still surface on the container (WireGuard peers table).
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
## Helpers
| Function | Description |
@@ -1346,8 +1695,22 @@ Render the toast notification container. Include in the main render root. See AP
| `enc(s)` | URL-encode a string (`encodeURIComponent`) |
| `$val(id)` | Get `value` of `document.getElementById(id)` |
| `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 |
## 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
The server handles caching headers for static assets. Browser cache invalidation is managed
@@ -1358,7 +1721,8 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. 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.
- **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`).
+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
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
### 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
@@ -20,26 +24,38 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
### 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)
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 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
- Debian 13 (trixie) target platform
- Python 3.13+, Flask 3.x for web management
- aiohttp (daemon server) + requests-unixsocket (Unix-socket client)
- firewalld (nftables backend)
- systemd-networkd (ip-lladdr, networkctl)
- nginx 1.26+
- systemd-networkd (networkctl)
- nginx
- dnsmasq
- 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
@@ -58,65 +74,107 @@ After installation, access the management interface at `https://<hostname>.local
## Project Structure
```
├── README.md # Project overview
├── AGENTS.md # Agent instructions
├── .gitignore
├── pyproject.toml # Project metadata + dependencies
├── scripts/ # Utility scripts
│ ├── install.sh # Deployment script (renders Jinja2 templates)
── update-vendor.sh # Download vendored libraries (acme.sh, htm)
├── pyproject.toml # Project metadata + dependencies
├── .venv/ # Python virtual environment
── update-vendor.sh # Download vendored libraries (acme.sh, htm)
│ ├── bootstrap_auth.py # Auth DB bootstrap (creates the operator user)
│ └── restart-services.sh # Restart installed system services
├── config/ # Declarative JSON configuration (source of truth)
│ ├── firewall/ # Firewall zone & rule config
│ ├── dnsmasq/ # DHCP/DNS config
│ ├── network/ # systemd-networkd per-interface config
│ ├── firewall/ # Firewall zone & rule config
│ ├── nginx/ # Proxy domain & SSL config
│ ├── wireguard/ # VPN interface & peer config
│ └── acme/ # ACME account settings (email, CA provider)
│ ├── nginx/ # Proxy backend, domain & SSL config
│ ├── wireguard/ # VPN access-class, interface & peer config
│ ├── acme/ # ACME account settings (email, CA provider)
│ └── auth/ # Authentication settings (JWT, WebAuthn)
├── 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/.htpasswd # Basic-auth entries for proxy domains
│ ├── dnsmasq/fragments/ # User config fragments
│ ├── acme/ # ACME certificates
│ ├── firewall/ # Pre-apply recovery snapshot
│ ├── logs/ # Application logs
│ ├── networkd/ # Generated 50-<name>.network files
│ └── wireguard/ # Generated WireGuard configs
│ ├── acme/ # acme.sh home: certs, account, webroot (www/)
│ ├── firewall/rules.json # Pre-apply recovery snapshot
│ ├── networkd/ # Generated 99-<name>.network files
│ ├── wireguard/ # Generated WireGuard configs
│ └── logs/ # Application logs
├── 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
│ ├── 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)
│ │ ── network.py # networkd handler (generate + apply)
├── system/ # System file templates (all Jinja2)
│ │ ── firewall.py # Zone/rich-rule CRUD + apply
│ │ ├── 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
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
│ │ ├── vacuum-wall-acme.timer # Renewal schedule
│ │ └── vacuum-walld.service # Privileged daemon (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)
│ ├── 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
│ ├── 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
│ ├── firewall.py # firewalld bindings
│ ├── network.py # systemd-networkd rendering & parsing
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
│ ├── nginx.py # Reverse proxy configuration (backends model)
│ ├── 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
│ ├── acme.py # Certificate management (ACME helpers)
│ ├── 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
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules (blueprints)
│ │ ├── common.py # Shared API response helpers (_ok, _error)
│ │ ├── firewall.py # Firewall API
│ │ ├── dhcp.py # DHCP/DNS API
│ │ ├── proxy.py # Nginx proxy API
│ │ ├── proxy.py # Nginx proxy API (domains + backends)
│ │ ├── certs.py # Certificate API
│ │ ├── wireguard.py # WireGuard 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)
│ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
│ │ ├── index.js # Barrel export of all public APIs
@@ -128,22 +186,32 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── websocket.js
│ │ ├── api.js
│ │ ├── helpers.js
│ │ ── components/ # Layout, data display, modal, toast
└── pages/ # Page modules (each defines a route via definePage)
│ │ ── html.js # htm.js tag adapter
│ ├── 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
│ ├── acme.sh # ACME certificate client
── htm.js # JS tagged-template HTML adapter
├── docs/ # Documentation
│ ├── overview.md # This file
│ ├── deployment.md
── api.md
│ ├── security.md
├── architecture.md
├── config.md
── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts
├── install.sh # Deployment script (renders Jinja2 templates)
── update-vendor.sh # Download vendored libraries (acme.sh, htm)
── htm.js # JS tagged-template HTML adapter
│ └── qrcode-svg-1.1.0.js # QR code generation (SVG)
├── tests/ # Test suites
│ ├── test_*.py # 28 Python modules (pytest; subprocess calls mocked)
── test-*.js # 9 JS test modules (hoover framework)
└── docs/ # Documentation
├── overview.md # This file
├── deployment.md
── api.md
├── security.md
├── architecture.md
── config.md
├── state-model.md # State schema, versions/tick diff, pending-changes model
└── hoover.md # Hoover SPA framework
```
## Documentation
@@ -153,4 +221,5 @@ After installation, access the management interface at `https://<hostname>.local
- [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design
- [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
+72 -42
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`.
- **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.
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
@@ -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 -t` | Nginx configuration syntax validation |
| 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 * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp * /etc/nginx/snippets/*` | 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 -- /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 | `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 |
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
| 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 | `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 |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| 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 |
| 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 addr show` | List IP addresses on interfaces |
| 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 reload` | Reload networkd for all interfaces |
| 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 | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
| 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:
- 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.
- 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
@@ -70,50 +69,57 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
### Management Interface
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. Static assets under `/static/` are served directly by nginx from `webui/static/` (unauthenticated, the same exposure as the Flask static route) with `Cache-Control: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive `Content-Security-Policy: default-src 'none'`.
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
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 Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age and `includeSubDomains` to prevent downgrade attacks.
- **Security headers** on all proxied responses:
- **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 `max-age=31536000; includeSubDomains` to prevent downgrade attacks.
- **Security headers** on all responses from the domain:
- `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing.
- `X-Frame-Options: DENY` — Prevents clickjacking via iframes.
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
- `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.
### JWT Authentication Lifecycle
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.
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.
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.
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()`.
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 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 `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. **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:
- Short-lived access tokens (15 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
- Short-lived access tokens (5 min) limit the window of exploitation
- Per-user signing-secret rotation on password/permission change plus the token blacklist prevent reuse after credential changes or logout
- `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 (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.
- **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).
- **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.
### 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
**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)
- `X-XSS-Protection` header
- Short-lived access tokens (15 min) with blacklist on logout
- 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/`)
- Short-lived access tokens (5 min) with secret rotation and blacklist on logout
### TLS Configuration
@@ -138,6 +143,15 @@ The default nginx SSL configuration enforces modern TLS only:
- **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`.
### 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
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 |
|---|---|---|
| `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 |
| 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 |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `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 |
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
| `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` | Restricts available address families |
| `IPAddressDeny` | `any` | Drops all network traffic by default |
| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach the other process at 127.0.0.1) |
| `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` (both units) | Drops all IP traffic by default |
| `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
### 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
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 |
|---|---|---|---|
| `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. |
| `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. |
| 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 (`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
+202 -27
View File
@@ -12,22 +12,53 @@ return annotation references them.
- Every collector return carries a top-level `timestamp` (ISO-8601).
- Subsystems with a declarative config expose pending state as a status
dict: `status: {"pending_changes": bool}`, **except firewall**, which
uses `pending: {config_pending() result}`.
dict: `status: {"pending_changes": bool, "pending_diff": [...]}`,
**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
store — WS snapshots and deltas skip `null` payloads so a failed
collector never overwrites good client data.
store. Null handling differs per push layer:
- **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
file itself: `_last_applied_config` (the full merged config at last
apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's
`status.pending_changes` is true when the current (merged) config hash
differs from the recorded hash; `status.pending_diff` lists the field
changes since that snapshot. All apply operations (including firewall
`config_apply`) re-stamp the baseline. These bookkeeping keys are
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset.
differs from the recorded hash — **or when no hash is recorded at all**
(the config was never applied). All apply operations (including
firewall `config_apply`) re-stamp the baseline. These bookkeeping keys
are internal and stripped from every state/API config payload.
Canceling pending changes (`POST /api/status/cancel-all`) restores a
pending config file from its snapshot; a subsystem with no recorded
baseline (never applied) is reported as skipped, not reset. Apply-all
and cancel-all operate on freshly re-collected state, not last-poll
state: every mutation ends with `emit_and_refresh()` → a synchronous
`refresh_state()` re-collection, so a saved edit is already reflected
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
@@ -35,10 +66,10 @@ return annotation references them.
| 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` |
| `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` |
| `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` |
| `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` |
@@ -54,18 +85,24 @@ Top-level `FirewallState`:
{
config: {}, // config/firewall/config.json
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
{name, mac, state, mtu, ips, ipv6, zone}
],
available_services: [str], // firewall-cmd --get-services
service_descriptions: {svc: str}, // one-line description from the
// firewalld service XML definitions
// (lib/firewall.py get_service_descriptions,
// cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any live zone —
// advisory coverage warning; empty =
// fully covered; NOT counted in pending
// firewalld service XML definitions
// (lib/firewall.py get_service_descriptions,
// cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any LIVE zone — a
// live-drift advisory (config may still
// cover them); distinct from the
// config-based interface-coverage
// invariant (docs/config.md); NOT
// counted in pending
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",
@@ -86,6 +123,9 @@ Notes:
(IPv6 list is separate).
- The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`);
`state.rich_rules` is the snake_case top-level re-derivation.
- `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
@@ -94,7 +134,8 @@ Notes:
config: {}, // config/dnsmasq/config.json, deep-merged
status: {
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: [
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
@@ -112,7 +153,8 @@ Notes:
{domain, path, backend, online, force_ssl, backend_name, cert,
[is_management], [is_websocket]}
],
status: {pending_changes: bool},
status: {pending_changes: bool,
pending_diff: [pending_change]},
timestamp: str,
}
```
@@ -126,10 +168,30 @@ Notes:
],
email: str,
account: {registered, email, ca, key_length},
status: {error: str|null}, // null on success; the cert-collection
// failure message otherwise (certs is
// then [])
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
```
@@ -140,7 +202,10 @@ Notes:
up: bool, // true when ANY managed iface is up
interface: {}, peers: [], // legacy single interface (wg0)
classes: {class: {up, interface, peers}}, // per wg-<class>
pending_changes: bool
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
{name, public_key, endpoint, allowed_ips,
@@ -166,7 +231,8 @@ Matches `parse_networkctl_status()` output (lib/network.py) exactly:
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
state, link}
},
status: {pending_changes: bool},
status: {pending_changes: bool,
pending_diff: [pending_change]},
timestamp: str,
}
```
@@ -184,10 +250,119 @@ Metrics only — no config, no pending state.
memory: {total, available, used, used_pct}, // bytes; 0-100
swap: {total, used, used_pct}, // bytes; 0-100
traffic: {iface: {rx_bytes, tx_bytes,
rx_packets, tx_packets}},
rx_packets, tx_packets}},
timestamp: str,
}
```
All four metric fields are volatile (1s tick cadence); structural diffs
only fire on interface-set changes.
All four metric fields (`load`, `memory`, `swap`, and the whole
`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).
+45 -2
View File
@@ -32,6 +32,11 @@ _ACME_ENVIRON = {
_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:
"""Locate the acme.sh binary on the system.
@@ -87,7 +92,7 @@ def _run_acme(args: list[str]) -> str:
acme_bin = _find_acme()
# 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] = [
acme_bin,
@@ -96,6 +101,17 @@ def _run_acme(args: list[str]) -> str:
"--config-home",
acme_home_env,
*args,
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
# runs (whose stdout is captured below) leave a persistent record
# of the raw CA exchange. The log file is passed explicitly (never
# as a bare trailing --log): a valueless trailing --log makes
# acme.sh's arg loop double-shift under dash (the --log branch
# shifts once, then the loop's trailing `shift 1` runs with zero
# positional params) and fails with "shift: can't shift that many"
# (exit 2). The explicit path keeps the same default destination
# ($LE_CONFIG_HOME/acme.sh.log) and can never swallow a real arg.
"--log",
str(Path(acme_home_env) / "acme.sh.log"),
]
try:
@@ -118,12 +134,38 @@ def _run_acme(args: list[str]) -> str:
if result.returncode != 0:
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
raise RuntimeError(
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
f"acme.sh failed with exit code {result.returncode}: "
f"{_summarize_acme_output(output)}"
)
return output
def _summarize_acme_output(output: str) -> str:
"""Reduce raw acme.sh output to a concise, human-readable summary.
acme.sh prints timestamped transcript lines; the failure reason is
in the final lines (e.g. "The retryafter=86400 value is too large
(> 600), will not retry anymore."). Strips per-line timestamps and
the "Please check log file" pointer so the summary stays toast-
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 = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines]
lines = [line for line in lines if not line.startswith("Please check log file")]
if not lines:
return "(no output)"
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:
"""Configure the default ACME contact email.
@@ -620,6 +662,7 @@ __all__ = [
"days_until_expiry",
"deploy",
"find_cert_dir",
"get_acme_home",
"get_cert_info",
"get_cert_paths",
"get_email",
+40
View File
@@ -0,0 +1,40 @@
"""Daemon-startup filesystem bootstrap.
Runs once at daemon startup, after the system-config import and before the
first state collection. Creates the runtime directories subsystems
read/write and persists the one-shot nginx legacy-format migration.
Config *files* are deliberately NOT created here: ``get_config`` reads are
pure and return in-memory defaults, and the system-config import must see
absent files in order to adopt live system state on first start. Files are
materialized on the first ``save_config`` (or by the import itself).
"""
from lib import dnsmasq, firewall, network, nginx, wireguard
from lib.common import ensure_dirs
__all__ = ["bootstrap"]
def bootstrap() -> None:
"""Create runtime directories and persist the one-shot nginx migration.
Idempotent existing directories are left untouched and the nginx
migration only rewrites the on-disk file when it actually changes.
"""
ensure_dirs(
dnsmasq.CONFIG_DIR,
dnsmasq.DATA_DIR,
dnsmasq.FRAGMENTS_DIR,
firewall.CONFIG_DIR,
firewall.DATA_DIR,
network.CONFIG_DIR,
network.DATA_DIR,
nginx.CONFIG_DIR,
nginx.SITES_DIR,
wireguard.CONFIG_PATH.parent,
)
# One-shot legacy-format migration for the nginx config (see
# ``lib.nginx.get_config``). Runs here, at startup, so read paths stay
# side-effect free.
nginx.migrate_config_file()
+19
View File
@@ -120,6 +120,24 @@ def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> Non
out.append({"path": path, "action": "changed", "old": old, "new": new})
def compute_pending(cfg: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]:
"""Return ``(pending_changes, pending_diff)`` from apply bookkeeping keys.
``pending_changes`` is ``True`` when the config was never applied or its
content no longer matches the recorded ``_last_applied_hash``. When
pending and a ``_last_applied_config`` snapshot is recorded, the diff is a
field-level comparison of the snapshot against the current (meta-stripped)
config; otherwise it is empty.
"""
pending = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(cfg)
diff: list[dict[str, Any]] = []
if pending:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
diff = deep_diff(snap, strip_apply_meta(cfg))
return pending, diff
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -301,6 +319,7 @@ __all__ = [
"_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY",
"_hash_password",
"compute_pending",
"config_hash",
"deep_diff",
"deep_merge",
+11 -2
View File
@@ -37,8 +37,12 @@ DEFAULT_CFG: dict[str, Any] = {
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
"""Load current dnsmasq config from JSON state file.
Pure read never writes or creates directories. Returns the in-memory
default when the file is missing; directories and the file are
materialized on the first ``save_config``.
"""
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
@@ -73,6 +77,11 @@ def set_domain(domain: str | None) -> None:
__all__ = [
"CONFIG_DIR",
"CONFIG_PATH",
"DATA_DIR",
"DEFAULT_CFG",
"FRAGMENTS_DIR",
"get_config",
"save_config",
"set_domain",
+72 -20
View File
@@ -7,6 +7,7 @@ All privileged commands are handled by daemon/handlers/firewall.py.
import logging
from collections.abc import Sequence
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -327,9 +328,16 @@ def _ensure_config_file() -> None:
def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
return load_json(CONFIG_FILE)
"""Return the declarative config from ``config/firewall/config.json``.
Pure read never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config`` (or
by the system-config import on first start).
"""
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
def save_config(cfg: dict[str, Any]) -> None:
@@ -370,11 +378,10 @@ def _compute_pending_changes(
Pure function no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
The interfaces diff is only reported for zones whose config explicitly
carries an ``interfaces`` key; zones with the key absent are hands-off
(apply keeps their live interfaces), so diffing them would advertise
changes that never happen. Likewise the target diff is only reported when
the config carries an explicit target that normalizes to something other
The config is the source of truth for zone interfaces: an absent
``interfaces`` key counts as an empty list, so every config zone is
diffed on interfaces. Likewise the target diff is only reported when the
config carries an explicit target that normalizes to something other
than ``default`` an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones.
@@ -387,18 +394,19 @@ def _compute_pending_changes(
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if "interfaces" in zone_cfg:
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
# The config is the source of truth for zone interfaces: an absent
# key counts as an empty list, so every config zone is diffed.
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.get("services", []))
@@ -488,6 +496,49 @@ def _compute_pending_changes(
}
def validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]:
"""Return network-managed interfaces with no firewall zone coverage.
Pure compares the declarative firewall config against the network
config; no live state. A managed interface is covered when it appears in
some zone's ``interfaces`` list (an absent key counts as empty), or is
explicitly declared in the top-level ``unmanaged`` list. ``lo`` and
``wg*`` interfaces are never guarded (VPN zones are managed by the
WireGuard sync; loopback is normally zoneless).
Args:
fw_cfg: Firewall declarative config (``zones`` plus optional
top-level ``unmanaged`` list).
net_cfg: Network config (``interfaces`` mapping).
Returns:
Sorted list of uncovered interface names; empty when the config is
valid.
"""
managed = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
if not managed:
return []
covered: set[str] = set()
for zone_cfg in fw_cfg.get("zones", {}).values():
if isinstance(zone_cfg, dict):
covered.update(
i for i in zone_cfg.get("interfaces", []) if isinstance(i, str)
)
unmanaged_raw = fw_cfg.get("unmanaged", [])
unmanaged = (
{i for i in unmanaged_raw if isinstance(i, str)}
if isinstance(unmanaged_raw, list)
else set()
)
return sorted(
name for name in managed if name not in covered and name not in unmanaged
)
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
"""Compare declarative config against firewalld live state, return diff.
@@ -554,4 +605,5 @@ __all__ = [
"load_backup",
"save_backup",
"save_config",
"validate_coverage",
]
+9 -5
View File
@@ -8,6 +8,7 @@ import contextlib
import ipaddress
import json
import logging
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -78,13 +79,16 @@ __all__ = [
def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json.
Pure read never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config``.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
def save_config(cfg: dict[str, Any]) -> None:
@@ -187,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.).
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] = []
d = cfg_entry
+28 -6
View File
@@ -169,15 +169,17 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
"""Load the current nginx config (pure read, in-memory migration).
Ensures config and sites directories exist, applies migrations for
legacy formats, then returns the config dict.
Never writes or creates directories. Returns the in-memory default when
the file is missing and applies legacy-format migration in memory, so
read paths (state collectors, apply-time checks) stay side-effect free.
The one-shot on-disk migration runs at daemon startup via
``migrate_config_file``.
Returns:
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
@@ -185,9 +187,27 @@ def get_config() -> dict[str, Any]:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
return _migrate_config(raw)
def migrate_config_file() -> bool:
"""Persist the one-shot legacy-format migration, if the file needs it.
Runs at daemon startup so ``get_config`` reads stay pure. Rewrites the
on-disk file only when migration actually changes it.
Returns:
True when the on-disk file was rewritten, False otherwise.
"""
raw = load_json(CONFIG_FILE)
if not raw:
return False
pre = deepcopy(raw)
raw = _migrate_config(raw)
save_config(raw)
return raw
if raw != pre:
save_config(raw)
return True
return False
def save_config(cfg: dict[str, Any]) -> None:
@@ -396,6 +416,7 @@ def generate_server_conf(
cert_key_path=cert_key_path,
domain_auth=domain_auth,
has_management=has_management,
static_root=str(PROJECT_DIR / "webui" / "static"),
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
@@ -610,6 +631,7 @@ __all__ = [
"get_config",
"get_domains",
"get_management_domains",
"migrate_config_file",
"remove_domain",
"save_config",
"test_config",
+5 -2
View File
@@ -227,7 +227,7 @@ class NginxState(TypedDict):
Attributes:
config: config/nginx/config.json.
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.
"""
@@ -280,15 +280,18 @@ class AcmeState(TypedDict):
"""ACME state (collector: `_collect_acme`).
Attributes:
certs: Certificate list.
certs: Certificate list (empty when collection failed).
email: Registered ACME email.
account: Account status (see AcmeAccount).
status: Collection status; ``error`` is ``None`` on success or
the failure message when cert collection was not possible.
timestamp: ISO-8601 collection time.
"""
certs: list[AcmeCert]
email: str
account: AcmeAccount
status: dict[str, str | None]
timestamp: str
+12 -993
View File
File diff suppressed because it is too large Load Diff
+34 -4
View File
@@ -8,10 +8,18 @@ caused by install.sh or manual edits to system files.
import contextlib
import logging
import re
from copy import deepcopy
from pathlib import Path
from typing import Any
from lib.common import load_json, run, save_json
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
load_json,
run,
save_json,
stamp_applied,
)
from lib.firewall import _live_target_to_config, _parse_all_zones_output
logger = logging.getLogger(__name__)
@@ -53,6 +61,24 @@ def import_all() -> list[str]:
return updated
def _carry_apply_meta(cfg: dict[str, Any], existing: dict[str, Any]) -> None:
"""Preserve apply bookkeeping when adopting live system state.
Imported content replaces the declarative config but must not destroy
the applied-state baseline. When *existing* carries apply meta keys,
they are copied over so pending-change detection and cancel-all keep
working against the last-applied baseline. When no baseline exists
(first import), *cfg* is stamped as applied the imported content is
exactly the state the system is currently running.
"""
if _APPLY_HASH_KEY in existing or _LAST_APPLIED_CONFIG_KEY in existing:
for key in (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY):
if key in existing:
cfg[key] = deepcopy(existing[key])
else:
stamp_applied(cfg)
# ------------------------------------------------------------------
# Dnsmasq
# ------------------------------------------------------------------
@@ -86,12 +112,13 @@ def import_dnsmasq() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping dnsmasq: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
@@ -247,12 +274,13 @@ def import_wireguard() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping wireguard: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
peer_count = len(cfg.get("peers", {}))
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
@@ -941,7 +969,9 @@ def import_firewall() -> bool:
logger.debug("Skipping firewall: no zones with interfaces")
return False
save_json(cfg_path, {"zones": zone_configs})
# Only reached when the config file is absent: the imported zones are
# exactly what firewalld is running, so stamp them as the applied state.
save_json(cfg_path, stamp_applied({"zones": zone_configs}))
logger.info(
"Imported firewall config: zones=%s",
", ".join(zone_configs.keys()),
+9 -4
View File
@@ -370,7 +370,7 @@ def status() -> dict[str, Any]:
if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []}
continue
class_status = _parse_wg_show_output(res.stdout.strip())
class_status = parse_wg_show_output(res.stdout.strip())
result["classes"][class_key] = class_status
if class_status["up"]:
result["up"] = True
@@ -382,7 +382,7 @@ def status() -> dict[str, Any]:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = _parse_wg_show_output(res.stdout.strip())
parsed = parse_wg_show_output(res.stdout.strip())
result["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", [])
@@ -392,8 +392,12 @@ def status() -> dict[str, Any]:
return result
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict."""
def parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict.
Returns ``{"up", "interface", "peers"}`` where *interface* carries
``public_key``, ``listen_port`` and (when present) ``fwmark``.
"""
result: dict[str, Any] = {
"up": False,
"interface": {},
@@ -742,6 +746,7 @@ __all__ = [
"get_peer_status",
"get_peers",
"initialize",
"parse_wg_show_output",
"remove_peer",
"save_config",
"set_listen_port",
+45
View File
@@ -144,6 +144,32 @@ fi
# Shared group: use the WebUI user's primary group
USER_GROUP=$(id -gn "$USER_NAME")
# Some appliance images ship with top-level system directories (and sometimes
# everything under them) owned by a regular user. This trips systemd-tmpfiles'
# "unsafe path transition" check and lets that user modify system paths.
# Repair the top level here; warn with a full-repair command if deeper
# mis-ownership is detected (depth-1 entries of /etc /usr /var /boot are
# always root-owned on Debian, so this check cannot false-positive).
_sys_dirs=(/ /bin /boot /etc /home /media /mnt /opt /root /sbin /srv /usr /var /var/lib /var/log)
_misowned=()
for _d in "${_sys_dirs[@]}"; do
[[ -e "$_d" ]] || continue
[[ "$(stat -c '%U' "$_d" 2>/dev/null)" == "root" ]] || _misowned+=("$_d")
done
if [[ ${#_misowned[@]} -gt 0 ]]; then
warn "System directories not owned by root: ${_misowned[*]}"
warn "Chowning to root:root (image shipped with mis-owned system paths)."
chown root:root "${_misowned[@]}"
_deep_count=$(find /etc /usr /var /boot -maxdepth 1 ! -user root 2>/dev/null | wc -l)
if [[ "$_deep_count" -gt 0 ]]; then
warn "Deeper mis-ownership detected ($_deep_count entries at depth 1)."
warn "Run a full repair, then re-run this installer:"
warn " sudo find / -xdev -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -path /run -prune -o -path /tmp -prune -o -path /home/$USER_NAME -prune -o -user $USER_NAME -print0 | xargs -0 -r chown root:root"
else
log "Repaired top-level system directory ownership."
fi
fi
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
@@ -205,6 +231,16 @@ mkdir -p "$ACME_HOME/deploy"
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"
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 ---
log "Creating config and data directories..."
@@ -212,6 +248,15 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
# nginx workers (www-data) serve webui/static directly from disk for the
# management domain — ensure read access regardless of checkout umask.
chmod -R a+rX "${PROJECT_DIR}/webui/static"
# ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work.
_d="${PROJECT_DIR}"
while [[ "$_d" != "/" && -n "$_d" ]]; do
chmod a+x "$_d" 2>/dev/null || true
_d="$(dirname "$_d")"
done
# 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 repo owner's git isn't tripped by git's dubious-ownership check.
+10
View File
@@ -53,6 +53,16 @@ server {
{% endif %}
{% for ppath, pcfg in paths.items() %}
{% if pcfg.is_management %}
# SPA static assets — served from disk, no Flask round-trip.
# no-cache: browsers revalidate every load; unchanged files are 304s.
location /static/ {
alias {{ static_root }}/;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'" always;
}
{% endif %}
{% if pcfg.is_websocket %}
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
location {{ ppath }} {
+8
View File
@@ -45,6 +45,14 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
# Sysctl
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
# ACME home permissions (acme.sh chmods its tree to owner-only modes:
# 700 on the config home, 600 on keys/confs — group access must be
# reopened so the shared two-user model can read the tree). Files only:
# the setgid directories (2775) already grant group rwx, and chmodding
# them would trip the daemon unit's RestrictSUIDSGID seccomp filter.
# The trailing * spans the file argument list.
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chmod g+rwX {{ ACME_HOME }}/*
# Misc
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
+9 -2
View File
@@ -3,8 +3,15 @@ Description=Vacuum Wall ACME Certificate Renewal
[Service]
Type=oneshot
User={{ USER_NAME }}
# Run as the daemon user, not the WebUI user: it owns the project tree
# (and the ACME home) in production, and acme.sh chmods its config home
# to 700 and its keys/confs to 600 on every run. Running as the WebUI
# user left the tree unreadable to the daemon (and vice versa) whenever
# the two users' runs interleaved.
User={{ USER_DAEMON_NAME }}
WorkingDirectory={{ PROJECT_DIR }}
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }}
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
# --log: persistent on-disk transcript of the raw CA exchange (journald
# captures stdout regardless; the file survives journal retention).
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }} --log
+5 -1
View File
@@ -2,8 +2,12 @@
Description=Vacuum Wall ACME Certificate Renewal Timer
[Timer]
# Daily only: ZeroSSL backs off a failed validation for 24h per domain
# (Retry-After: 86400). With two runs a day every attempt landed inside
# the previous attempt's backoff window, re-arming it — a permanent
# renewal lockout. Attempts >24h apart are required for the backoff to
# ever expire (acme.sh discussion #6419).
OnCalendar=*-*-* 00:00:00
OnCalendar=*-*-* 12:00:00
Persistent=true
RandomizedDelaySec=300
+6
View File
@@ -16,6 +16,12 @@ TimeoutStopSec=15
Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
# acme.sh routes its _info/_err lines through logger(1) -> journald when
# SYS_LOG is set (default: off). This journals manual issue/renew runs
# in real time under this unit, whose subprocess stdout is otherwise
# captured by the daemon and never seen by the journal.
# Levels: 3=error, 6=info, 7=debug.
Environment=SYS_LOG=6
Environment=HOME={{ PROJECT_DIR }}
# Runtime directories created before namespace setup. ProtectSystem=strict
+34 -1
View File
@@ -5,7 +5,7 @@
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
*/
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
@@ -235,5 +235,38 @@ test('buildRows row VNodes have correct tag', () => {
}
});
// === applyResultToasts ===
// apply-all returns 200 with { applied, errors } even when subsystems
// failed — resp.ok alone is not a success signal; errors must win.
test('applyResultToasts: errors suppress the success toast', () => {
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
assertEq(t.success, null, 'no success toast when errors exist');
assertIncludes(t.error, 'Firewall — refused');
});
test('applyResultToasts: success toast when applied and no errors', () => {
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, 'All changes applied');
});
test('applyResultToasts: no toast when nothing applied and no errors', () => {
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, null);
});
test('applyResultToasts: multiple errors are joined', () => {
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
assertIncludes(t.error, 'Firewall — a');
assertIncludes(t.error, 'Nginx — b');
});
test('applyResultToasts: null payload is safe', () => {
const t = applyResultToasts(null, 'ok');
assertEq(t.error, null);
assertEq(t.success, null);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+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');
});
/* ── 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 ────────────────────────────────────────────────── */
(async () => {
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests for hoover/dirty.js pending-edit marker matching.
*
* dirty.js has no imports DOM-free at import, so the tests run under
* plain node (same pattern as test-model-set.js).
*
* Run with `node tests/test-dirty.js`.
*/
import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── dirtySet ────────────────────────────────────────────────── */
test('dirtySet collects pending paths from pending_diff', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[0].start', action: 'changed' },
{ path: 'dns.domain', action: 'added' },
]});
assert(set.has('dhcp.ranges[0].start'), 'first path collected');
assert(set.has('dns.domain'), 'second path collected');
assertEq(set.size, 2, 'exactly two paths');
});
test('dirtySet skips diff entries without a path', () => {
const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] });
assertEq(set.size, 1, 'only well-formed entries');
assert(set.has('a.b'), 'valid path collected');
});
test('dirtySet is empty when pending_diff is absent', () => {
assertEq(dirtySet(null).size, 0, 'null status');
assertEq(dirtySet({}).size, 0, 'empty status');
assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff');
});
/* ── never-applied sentinel ──────────────────────────────────── */
test('dirtySet marks everything dirty when saved but never applied', () => {
const set = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(set.size, 1, 'sentinel only');
assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty');
assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty');
assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip');
});
test('dirtySet has no sentinel when there is no pending state', () => {
const set = dirtySet({ pending_changes: false, pending_diff: [] });
assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending');
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean');
});
test('dirtySet has no sentinel when a real diff exists', () => {
const set = dirtySet({
pending_changes: true,
pending_diff: [{ path: 'dns.domain', action: 'changed' }],
});
assert(isDirty(set, 'dns.domain'), 'matching path is dirty');
assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean');
assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel');
});
/* ── line matching ───────────────────────────────────────────── */
test('isDirty matches an exact pending leaf', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty');
});
test('a pending list marks every indexed row (ancestor of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] });
for (const i of [0, 1, 12]) {
assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`);
assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`);
}
});
test('a pending row field marks the list (descendant of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] });
assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty');
assert(isDirty(set, 'dhcp'), 'the top-level container is dirty');
});
test('unrelated paths do not match', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges'), 'different root');
});
test('index brackets do not prefix-match across digits', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12');
assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10');
assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty');
});
test('plain keys do not prefix-match similar names', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0');
assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2');
});
test('isDirty is false for an empty or missing set', () => {
assert(!isDirty(new Set(), 'a.b'), 'empty set');
assert(!isDirty(null, 'a.b'), 'null set');
assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending');
});
test('isDirty tolerates an empty path', () => {
const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] });
assert(!isDirty(set, ''), 'empty element path is not dirty');
assert(!isDirty(set, null), 'null element path is not dirty');
});
/* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */
test('dirtyTitle lists all matching pending paths sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[1].start', action: 'changed' },
{ path: 'dhcp.ranges[0].end', action: 'changed' },
{ path: 'dns.domain', action: 'changed' },
]});
assertEq(
dirtyTitle(set, 'dhcp.ranges'),
'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start',
'both rows listed, sorted, unrelated path excluded',
);
});
test('dirtyTitle is empty when the element is clean', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element');
});
test('dirtyInfo returns the full marker object', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
const hit = dirtyInfo(set, 'dns.domain');
assertEq(hit.dirty, true, 'dirty flag');
assertEq(hit.class, 'config-dirty', 'class');
assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip');
const miss = dirtyInfo(set, 'dhcp.ranges');
assertEq(miss.dirty, false, 'clean flag');
assertEq(miss.class, '', 'clean class');
assertEq(miss.title, '', 'clean title');
});
/* ── orphanInfo (removed dict keys) ──────────────────────────── */
test('orphanInfo flags a removed peer with no live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']);
assertEq(info.dirty, true, 'orphan is dirty');
assertEq(info.class, 'config-dirty', 'orphan class');
assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip');
});
test('orphanInfo is clean when the pending path still has a live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan');
});
test('orphanInfo flags a removed peer when no peers remain', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands');
});
test('orphanInfo ignores pending paths outside the root', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root');
});
test('orphanInfo is clean when the root itself is pending', () => {
// A whole-dict `peers` change marks every child row instead; the
// container-level marker would be redundant.
const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan');
assert(isDirty(set, 'peers.p1'), 'but the rows are still marked');
});
test('orphanInfo is clean for an empty set or the never-applied sentinel', () => {
assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set');
const sentinel = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it');
});
test('orphanInfo lists multiple orphans sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'peers.b', action: 'removed' },
{ path: 'peers.a', action: 'removed' },
{ path: 'peers.c.field', action: 'changed' },
]});
const info = orphanInfo(set, 'peers', ['peers.c']);
assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted');
});
/* ── firewall zone + type granularity ────────────────────────── */
test('fwDirty builds a zone-to-types map', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'services' },
{ zone: 'public', type: 'rich_rules' },
{ zone: 'dmz', type: 'interfaces' },
{ zone: null },
{ zone: 'lan' },
],
});
assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)');
assert(m.get('public').has('services'), 'public services');
assert(m.get('public').has('rich_rules'), 'public rich_rules');
assert(m.get('dmz').has('interfaces'), 'dmz interfaces');
assert(m.get('lan').size === 0, 'typeless zone has an empty type set');
});
test('fwIsDirty by zone and by zone+type', () => {
const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] });
assert(fwIsDirty(m, 'public'), 'zone-only match');
assert(fwIsDirty(m, 'public', 'services'), 'zone+type match');
assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type');
assert(!fwIsDirty(m, 'dmz'), 'unknown zone');
assert(!fwIsDirty(new Map(), 'public'), 'empty map');
});
test('fwInfo and fwTitle carry the pending types', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'rich_rules' },
{ zone: 'public', type: 'services' },
],
});
const zone = fwInfo(m, 'public');
assertEq(zone.dirty, true, 'zone dirty');
assertEq(zone.class, 'config-dirty', 'zone class');
assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types');
const typed = fwInfo(m, 'public', 'services');
assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type');
assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean');
assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone');
});
/* ── 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;
})();
+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;
})();
+51
View File
@@ -58,6 +58,25 @@ class TestRunAcme:
assert cmd[0] == "/usr/local/bin/acme.sh"
assert "sudo" not in cmd
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_log_flag_is_last(self, mock_run, mock_find):
# --log <file> must trail the subcommand args: acme.sh would
# otherwise consume the first subcommand arg as its file argument.
# The explicit file path (not a bare trailing --log) is required
# because a valueless trailing --log makes acme.sh's arg loop
# double-shift under dash and fail with "shift: can't shift that
# many".
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
acme._run_acme(["--issue", "-d", "example.com"])
cmd = mock_run.call_args[0][0]
assert cmd.count("--log") == 1
assert cmd[-2] == "--log"
assert cmd[-1].endswith("acme.sh.log")
assert cmd.index("--issue") < cmd.index("--log")
assert "example.com" in cmd
class TestParseListOutput:
def test_parses_single_entry(self):
@@ -258,3 +277,35 @@ class TestHasAutoRenew:
with patch.object(acme, "_ACME_HOME", acme_dir):
result = acme._has_auto_renew("nonexistent.com")
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"
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
import pytest
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
"""Point every bootstrap-referenced path into a throwaway tree."""
cfg = tmp_path / "config"
data = tmp_path / "data"
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
return tmp_path
def test_creates_runtime_dirs(sandbox):
bootstrap.bootstrap()
assert dnsmasq.FRAGMENTS_DIR.is_dir()
assert firewall.DATA_DIR.is_dir()
assert network.DATA_DIR.is_dir()
assert nginx.SITES_DIR.is_dir()
assert wireguard.CONFIG_PATH.parent.is_dir()
def test_does_not_create_config_files(sandbox):
# Config files are left for system-import (first start) or the first
# save_config — bootstrap must not pre-empt either.
bootstrap.bootstrap()
assert not nginx.CONFIG_FILE.exists()
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
assert not (network.CONFIG_DIR / "config.json").exists()
assert not (firewall.CONFIG_DIR / "config.json").exists()
assert not wireguard.CONFIG_PATH.exists()
def test_persists_nginx_migration(sandbox):
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
bootstrap.bootstrap()
on_disk = nginx.get_config()
assert on_disk["backends"]["webui"]["_migrated"] is True
raw = nginx.CONFIG_FILE.read_text()
assert '"_migrated": true' in raw or '"_migrated":True' in raw
def test_idempotent(sandbox):
nginx.save_config({"domains": {}})
bootstrap.bootstrap()
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
bootstrap.bootstrap()
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
+51
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
compute_pending,
config_hash,
deep_diff,
load_json,
@@ -78,6 +79,56 @@ class TestDeepDiff:
assert not any(p.startswith("z.ranges[0].n") for p in paths)
class TestComputePending:
def test_hash_match_no_pending(self):
cfg = {"a": 1}
stamp_applied(cfg)
pending, diff = compute_pending(cfg)
assert pending is False
assert diff == []
def test_never_applied_pending_no_snapshot(self):
pending, diff = compute_pending({"a": 1})
assert pending is True
assert diff == []
def test_never_applied_pending_with_foreign_snapshot(self):
# A recorded snapshot that does not match the current hash is still
# used for the diff.
cfg = {"a": 2, _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
def test_hash_mismatch_with_snapshot_diffs(self):
applied = {"zones": {"lan": {"services": ["http"]}}}
stamped = dict(applied)
stamp_applied(stamped)
drifted = {"zones": {"lan": {"services": ["http", "ssh"]}}}
drifted[_LAST_APPLIED_CONFIG_KEY] = applied
drifted[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
pending, diff = compute_pending(drifted)
assert pending is True
paths = {d["path"] for d in diff}
assert "zones.lan.services" in paths
def test_hash_mismatch_snapshot_not_dict(self):
cfg = {"a": 1, _LAST_APPLIED_CONFIG_KEY: "not-a-dict"}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == []
def test_meta_keys_excluded_from_diff(self):
cfg = {"a": 1}
stamp_applied(cfg)
cfg["a"] = 2 # drift
pending, diff = compute_pending(cfg)
assert pending is True
assert not any(
p.startswith(("_last_applied",)) for d in diff for p in [d["path"]]
)
class TestDashboardFallback:
def test_hash_subsystem_unchanged_generic(self):
# Guards that a pending status without a snapshot still yields a
+310 -83
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from daemon.handlers import common as daemoncommon
from daemon.handlers import firewall as daemonfirewall
from daemon.server import ConflictError, NotFoundError
from lib import firewall
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
so their interfaces diff must not be reported; other field drift is."""
"""The config is the source of truth for zone interfaces: an absent
'interfaces' key counts as an empty list, so every config zone is
diffed on interfaces (no hands-off zones)."""
def test_services_drift_reported_without_interfaces_key(self):
def test_services_and_interfaces_drift_reported_without_interfaces_key(self):
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
types = {c["type"] for c in result["pending"]}
assert "services" in types
assert "interfaces" not in types
# Absent key counts as an empty list: live eth0 is a pending removal.
assert "interfaces" in types
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
# Config in sync on everything except a missing interfaces key: the
# zone's live interfaces are intentionally left alone by apply.
def test_absent_key_zone_in_sync_live_reports_nothing(self):
# Config lacks the interfaces key and the live zone has no
# interfaces either — absent key equals the empty live set.
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
result = firewall._compute_pending_changes(cfg, {"public": live})
assert result["pending"] == []
assert result["needs_apply"] is False
@@ -457,6 +459,51 @@ class TestTargetDriftSemantics:
# ---------------------------------------------------------------------------
class TestValidateCoverage:
def test_all_covered(self):
fw = {"zones": {"public": {"interfaces": ["eth0"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_uncovered_reported_sorted(self):
fw = {"zones": {"public": {"interfaces": ["eth1"]}}}
net = {"interfaces": {"eth5": {}, "eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0", "eth5"]
def test_unmanaged_exempts(self):
fw = {
"zones": {"public": {"interfaces": ["eth1"]}},
"unmanaged": ["eth0"],
}
net = {"interfaces": {"eth0": {}, "eth1": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_lo_and_wg_exempt(self):
fw = {"zones": {}}
net = {"interfaces": {"lo": {}, "wg0": {}, "wg-full": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_absent_key_counts_as_empty(self):
# A zone without an 'interfaces' key covers nothing.
fw = {"zones": {"public": {"services": ["http"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_empty_network_config(self):
assert firewall.validate_coverage({"zones": {}}, {"interfaces": {}}) == []
assert firewall.validate_coverage({"zones": {}}, {}) == []
def test_non_dict_zone_and_non_list_unmanaged_ignored(self):
fw = {"zones": {"public": "oops"}, "unmanaged": "eth0"}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_non_string_entries_ignored(self):
fw = {"zones": {"public": {"interfaces": [None, 7]}}, "unmanaged": [None]}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
class TestGetZoneInfo:
def test_parses_zone_info(self):
result = firewall._parse_zone_output(
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
# ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface
# in no zone (clients lose connectivity/DHCP) unless forced.
# Coverage invariant: the config must cover every network-managed
# interface (or declare it unmanaged). Pure config check — the config is
# the source of truth for zone interfaces (absent key = empty), so there
# is no live-state comparison and no hands-off zones.
# ---------------------------------------------------------------------------
@@ -849,7 +898,7 @@ def _apply_with(
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg),
@@ -860,21 +909,88 @@ def _apply_with(
return result, mock_run
class TestDaemonInterfaceCoverageGuard:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
class TestDaemonCoverageInvariant:
def test_conflict_when_network_iface_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run") as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
msg = str(exc.value)
assert "eth0" in msg
assert "unmanaged" in msg
assert "force" in msg
mock_backup.assert_not_called()
# Pure config check: the guard performs no live-state reads at all.
mock_run.assert_not_called()
def test_unmanaged_exempts_iface(self):
cfg = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front.
assert mock_run.call_args_list[0].args[0] == [
cmds = [c.args[0] for c in mock_run.call_args_list]
# Live eth0 is removed, config eth1 added exactly once (permanent).
assert [
"firewall-cmd",
"--get-active-zones",
]
# Hands off: no interface mutation commands for the absent-key zone.
for c in mock_run.call_args_list:
for arg in c.args[0]:
assert not arg.startswith("--remove-interface=")
assert not arg.startswith("--add-interface=")
"--zone=public",
"--remove-interface=eth0",
"--permanent",
] in cmds
assert (
cmds.count(
["firewall-cmd", "--zone=public", "--add-interface=eth1", "--permanent"]
)
== 1
)
def test_absent_key_zone_counts_as_empty(self):
# No hands-off zones: a zone without an 'interfaces' key covers
# nothing, so a managed interface left out of every zone blocks.
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run"),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "eth0" in str(exc.value)
def test_live_only_zone_does_not_count_as_covered(self):
# eth1 is held by 'guest' live but the config (the source of
# truth) does not cover it — apply is blocked regardless of live.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth1": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\nguest\n eth1\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError),
):
daemonfirewall._config_apply()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
)
def test_conflict_when_network_iface_goes_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
class TestDaemonSaveCoverageValidation:
"""The coverage invariant is enforced at save time too (POST/PATCH
/firewall/config), so bad configs are rejected before they are written."""
def test_save_blocks_uncovered(self):
body = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall._config_apply()
daemonfirewall.save_config_handler(None, body)
assert "eth0" in str(exc.value)
assert "force" in str(exc.value)
assert "unmanaged" in str(exc.value)
mock_save.assert_not_called()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_live_only_zone_interfaces_count_as_covered(self):
# eth1 is held by 'guest', which is live but absent from the config —
# apply never touches it, so eth1 counts as covered.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
assert result["applied_zones"] == ["public"]
def test_coverage_guard_conflict_writes_no_backup(self):
# Guard conflict must be side-effect free, like the lockout conflict.
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
def test_save_allows_unmanaged(self):
body = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
daemonfirewall._config_apply()
mock_backup.assert_not_called()
# Only the guard's live-zone read ran — no mutation commands at all.
assert [c.args[0] for c in mock_run.call_args_list] == [
["firewall-cmd", "--get-active-zones"]
]
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.save_config_handler(None, body)
assert result == {"config_saved": True}
mock_save.assert_called_once()
def test_save_rejects_non_list_unmanaged(self):
body = {"zones": {"public": {"interfaces": ["eth0"]}}, "unmanaged": "eth0"}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
pytest.raises(ValueError) as exc,
):
daemonfirewall.save_config_handler(None, body)
assert "unmanaged" in str(exc.value)
def test_patch_blocks_merge_that_uncovers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"public": {"interfaces": ["eth1"]}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall.patch_config(None, body)
assert "eth0" in str(exc.value)
mock_save.assert_not_called()
def test_patch_allows_merge_that_covers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"internal": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.patch_config(None, body)
assert result == {"config_saved": True}
saved = mock_save.call_args[0][0]
assert set(saved["zones"]) == {"public", "internal"}
# ---------------------------------------------------------------------------
@@ -957,8 +1093,8 @@ class TestDaemonCreateZone:
with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body)
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
) as mock_backup,
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"),
):
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG),
@@ -1099,6 +1235,97 @@ class TestDaemonConfigApplyStamp:
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
class TestDaemonMutatorBaselineStamp:
"""Per-zone mutations apply to live firewalld immediately and must
re-stamp the applied baseline, so cancel-all reverts to the post-mutation
state instead of an older snapshot (regression: stale install-era
snapshot resurrected a phantom 'remove interface' pending change).
"""
ZONES_OUT = "public\ninternal"
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_interfaces(
None, {"zone": "internal", "interfaces": ["eth1"]}
)
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["interfaces"] == ["eth1"]
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["interfaces"] == [
"eth1"
]
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
def test_set_zone_services_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with (
patch.object(
daemonfirewall, "_parse_zone_output", return_value={"services": []}
),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_services(
None, {"zone": "internal", "services": ["ssh"]}
)
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["services"] == ["ssh"]
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["services"] == [
"ssh"
]
@patch("daemon.handlers.firewall._reload")
@patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"internal": {}}},
)
@patch("daemon.handlers.firewall.run")
def test_set_masquerade_syncs_config_and_stamps(
self, mock_run, mock_cfg, mock_reload
):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["masquerade"] is True
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run")
def test_set_masquerade_no_config_entry_skips_write(
self, mock_run, mock_cfg, mock_reload
):
"""A zone absent from the config must not gain a bare entry — that
would manufacture spurious service diffs on the next poll."""
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
mock_save.assert_not_called()
class TestDaemonGetConfigEndpoint:
def test_strips_apply_meta(self):
with patch.object(
@@ -1117,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
"daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
)
@patch("daemon.handlers.firewall.bus")
@patch("daemon.handlers.common.bus")
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
with patch("daemon.handlers.firewall.refresh_state"):
with patch("daemon.handlers.common.refresh_state"):
daemonfirewall.config_apply(None, None)
mock_apply.assert_called_once_with(force=False)
mock_apply.reset_mock()
+72
View File
@@ -1,6 +1,7 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import inspect
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -1303,3 +1304,74 @@ class TestGetRenewStatus:
assert status["domain"] == "example.com"
assert status["status"] == "completed"
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
class TestNormalizeAcmeHome:
def test_normalize_invokes_sudo_chmod_on_files(self, tmp_path):
f1 = tmp_path / "account.conf"
f1.write_text("x")
(tmp_path / "sub").mkdir()
f2 = tmp_path / "sub" / "dom.key"
f2.write_text("x")
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch(
"lib.common.run_proc",
return_value=MagicMock(returncode=0, stderr=""),
) as mock_proc,
):
acme_mod.normalize_acme_home()
args = mock_proc.call_args.args[0]
assert args[:2] == ["chmod", "g+rwX"]
assert set(args[2:]) == {str(f1), str(f2)}
mock_proc.assert_called_once()
def test_normalize_empty_tree_skips_sudo(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch("lib.common.run_proc") as mock_proc,
):
acme_mod.normalize_acme_home()
mock_proc.assert_not_called()
def test_normalize_failure_does_not_raise(self, tmp_path):
(tmp_path / "a.conf").write_text("x")
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch(
"lib.common.run_proc",
return_value=MagicMock(returncode=1, stderr="denied"),
),
patch.object(acme_mod, "logger"),
):
acme_mod.normalize_acme_home()
def test_preflight_normalizes_before_run(self):
calls = []
with (
patch.object(
acme_mod,
"normalize_acme_home",
side_effect=lambda: calls.append("normalize"),
),
patch.object(
acme_mod,
"_run_acme",
side_effect=lambda args: calls.append("run:" + " ".join(args)) or "ok",
),
):
out = acme_mod._run_acme_preflight(["--list", "--listraw"])
assert calls == ["normalize", "run:--list --listraw"]
assert out == "ok"
class TestPreflightWiring:
def test_issue_uses_preflight(self):
source = inspect.getsource(acme_mod._run_issue)
assert "_run_acme_preflight" in source
assert "normalize_acme_home" in source
def test_renew_uses_preflight(self):
source = inspect.getsource(acme_mod._run_renew)
assert "_run_acme_preflight" in source
assert "normalize_acme_home" in source
+23 -5
View File
@@ -15,18 +15,36 @@ from daemon.handlers.network import (
save_interface,
set_sysctl,
)
from lib import dnsmasq as _dm
from lib import firewall as _fw
from lib import network as _net
@pytest.fixture
def tmp_network(tmp_path):
orig_config = _net.CONFIG_FILE
orig_data = _net.DATA_DIR
# Handler endpoints emit "networkd" sync events; the subscribers
# (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq
# configs, and apply_all re-stamps the dnsmasq config. Point all of
# those paths at tmp so tests never touch the real config files.
orig_net = (_net.CONFIG_FILE, _net.DATA_DIR)
orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR)
orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE)
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
_net.DATA_DIR = tmp_path / "data" / "networkd"
_dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq"
_dm.DATA_DIR = tmp_path / "data" / "dnsmasq"
_dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json"
_dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments"
_dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_dm.DATA_DIR.mkdir(parents=True, exist_ok=True)
_dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
_fw.CONFIG_DIR = tmp_path / "config" / "firewall"
_fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json"
_fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
_net.CONFIG_FILE = orig_config
_net.DATA_DIR = orig_data
_net.CONFIG_FILE, _net.DATA_DIR = orig_net
_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm
_fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw
# =================================================================
@@ -363,7 +381,7 @@ class TestInferEndpoints:
class TestSetSysctl:
def test_set_sysctl_success(self):
def test_set_sysctl_success(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch.object(Path, "read_text", return_value="1"),
+3 -2
View File
@@ -29,10 +29,11 @@ class TestGetConfig:
assert isinstance(cfg, dict)
assert "interfaces" in cfg
def test_creates_config_file(self, tmp_network):
def test_missing_file_returns_default_without_writing(self, tmp_network):
# Pure read: get_config never materializes the file.
cfg = _net.get_config()
assert _net.CONFIG_FILE.exists()
assert cfg["interfaces"] == {}
assert not _net.CONFIG_FILE.exists()
class TestSaveConfig:
+12 -10
View File
@@ -279,18 +279,18 @@ class TestStateParserDedup:
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
def test_state_uses_network_parser(self):
"""The networkd collector in state.py should import from lib.network."""
import lib.state as _state
"""The networkd collector should import from lib.network."""
import daemon.collectors.networkd as _collector
source = Path(_state.__file__).read_text()
assert "from lib.network import parse_networkctl_status" in source
source = Path(_collector.__file__).read_text()
assert "from lib.network import" in source
assert "parse_networkctl_status" in source
def test_networkd_collector_returns_correct_format(self):
"""_collect_networkd should return interfaces dict + timestamp."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run") as mock_run:
with patch("daemon.collectors.networkd.run") as mock_run:
mock_run.return_value = json.dumps(
{
"Interfaces": [
@@ -320,7 +320,7 @@ class TestStateParserDedup:
]
}
)
result = _state._collect_networkd()
result = _collector._collect_networkd()
assert "interfaces" in result
assert "timestamp" in result
@@ -329,10 +329,12 @@ class TestStateParserDedup:
def test_networkd_collector_handles_failure(self):
"""_collect_networkd returns empty interfaces on error."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
result = _state._collect_networkd()
with patch(
"daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
):
result = _collector._collect_networkd()
assert result["interfaces"] == {}
assert "timestamp" in result
+101 -1
View File
@@ -55,6 +55,44 @@ class TestGetConfig:
assert "ssl" in cfg
assert cfg["domains"] == {}
def test_read_does_not_rewrite_unchanged_file(self, temp_data_dir):
"""get_config() must not re-save a file that needs no migration."""
nginx.save_config(
{
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {"app.example.com": {"backend": "webui"}},
"ssl": {"protocols": "TLSv1.3"},
}
)
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config()
assert cfg["domains"] == {"app.example.com": {"backend": "webui"}}
# No churn: reading a current-format config leaves the file alone.
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
"""get_config() is pure: migration is applied in memory, file untouched."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config()
# Migration added the builtin webui backend (in memory only).
assert cfg["backends"]["webui"]["_migrated"] is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_migrate_config_file_persists_legacy(self, temp_data_dir):
"""migrate_config_file() rewrites the file when migration changes it."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
assert nginx.migrate_config_file() is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
# Idempotent: a second run is a no-op.
assert nginx.migrate_config_file() is False
def test_migrate_config_file_noop_when_missing(self, temp_data_dir):
assert not nginx.CONFIG_FILE.exists()
assert nginx.migrate_config_file() is False
assert not nginx.CONFIG_FILE.exists()
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
@@ -266,9 +304,71 @@ class TestGenerateServerConf:
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9090;" in out
assert "add_header X-Content-Type-Options" not in out
# Server-level security headers come from Flask, not nginx
assert "Strict-Transport-Security" not in out
assert "Referrer-Policy" not in out
assert "wall_mgmt_access.log" in out
def test_management_static_location(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
static_root = str(nginx.PROJECT_DIR / "webui" / "static")
assert "location /static/ {" in out
assert f"alias {static_root}/;" in out
assert 'add_header Cache-Control "no-cache" always;' in out
assert "add_header X-Content-Type-Options nosniff always;" in out
assert (
"add_header Content-Security-Policy \"default-src 'none'\" always;" in out
)
def test_static_location_only_for_management_root(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
}
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
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):
cfg = {
"domain": "mgmt.example.com",
+26 -15
View File
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
import json
from unittest.mock import Mock, patch
import lib.state
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
import daemon.collectors.networkd
import daemon.collectors.nginx
import daemon.collectors.system
import daemon.collectors.wireguard
from lib import schema
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
class TestCollectorShapesMatchSchema:
def test_firewall_state(self):
with (
patch.object(lib.state, "run") as mock_run,
patch.object(daemon.collectors.firewall, "run") as mock_run,
patch.object(
lib.state,
daemon.collectors.firewall,
"_network_get_config",
return_value={
"interfaces": {
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
return ""
mock_run.side_effect = run_side
result = lib.state._collect_firewall()
result = daemon.collectors.firewall._collect_firewall()
assert not _missing(schema.FirewallState.__required_keys__, result)
for iface in result["interfaces"]:
@@ -74,37 +80,40 @@ class TestCollectorShapesMatchSchema:
assert result["uncovered_interfaces"] == ["eth1"]
def test_dnsmasq_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.dnsmasq, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = lib.state._collect_dnsmasq()
result = daemon.collectors.dnsmasq._collect_dnsmasq()
assert not _missing(schema.DnsmasqState.__required_keys__, result)
for k in schema.DnsmasqStatus.__required_keys__:
assert k in result["status"], f"DnsmasqStatus missing {k}"
def test_nginx_state(self):
result = lib.state._collect_nginx()
result = daemon.collectors.nginx._collect_nginx()
assert not _missing(schema.NginxState.__required_keys__, result)
assert "pending_changes" in result["status"]
def test_acme_state(self):
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
lib.state,
daemon.collectors.acme,
"_parse_account_conf",
return_value={"registered": False, "email": "", "ca": ""},
),
):
result = lib.state._collect_acme()
result = daemon.collectors.acme._collect_acme()
assert not _missing(schema.AcmeState.__required_keys__, result)
assert result["status"]["error"] is None
def test_wireguard_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.wireguard, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="", returncode=1)
result = lib.state._collect_wireguard()
result = daemon.collectors.wireguard._collect_wireguard()
assert not _missing(schema.WgState.__required_keys__, result)
for k in schema.WgStatus.__required_keys__:
@@ -134,8 +143,10 @@ class TestCollectorShapesMatchSchema:
}
]
}
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
result = lib.state._collect_networkd()
with patch.object(
daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
):
result = daemon.collectors.networkd._collect_networkd()
assert not _missing(schema.NetworkdState.__required_keys__, result)
assert "eth0" in result["interfaces"]
@@ -147,7 +158,7 @@ class TestCollectorShapesMatchSchema:
def test_system_state(self):
"""Reads /proc and /sys directly — no mocking needed on Linux."""
result = lib.state._collect_system()
result = daemon.collectors.system._collect_system()
assert not _missing(schema.SystemState.__required_keys__, result)
for k in schema.CpuLoad.__required_keys__:
assert k in result["load"], f"CpuLoad missing {k}"
+228 -12
View File
@@ -1,8 +1,12 @@
"""Tests for lib/state.py — state store and collect functions."""
import json
import os
from unittest.mock import patch
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
from lib.state import State, state
@@ -50,9 +54,9 @@ class TestState:
class TestCollectAll:
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_returns_dict(self, mock_run):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -87,10 +91,10 @@ class TestCollectAll:
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -143,10 +147,10 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions")
@patch("lib.state.run")
@patch("daemon.collectors.firewall.get_service_descriptions")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -167,11 +171,11 @@ class TestCollectAll:
mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
from lib.state import _collect_dnsmasq
from daemon.collectors.dnsmasq import _collect_dnsmasq
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
@@ -180,12 +184,12 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
from daemon.collectors.dnsmasq import _collect_dnsmasq
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = {
@@ -225,7 +229,9 @@ class TestCollectAll:
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
monkeypatch.setattr(
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
@@ -249,6 +255,216 @@ class TestCollectFailure:
assert s.is_populated() is False
_ACCOUNT = {"registered": False, "email": "", "ca": ""}
class TestAcmeCollectNonFatal:
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
def test_list_failure_yields_empty_certs_and_error(self):
from daemon.collectors.acme import _collect_acme
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"),
patch(
"lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"),
),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
assert result["certs"] == []
assert result["email"] == "a@b.c"
assert result["status"]["error"] is not None
assert "exit code 2" in result["status"]["error"]
def test_success_reports_no_error(self):
from daemon.collectors.acme import _collect_acme
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"),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
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:
def test_version_starts_at_zero(self):
s = State()
+39
View File
@@ -377,6 +377,45 @@ class TestStatusApplyAll:
assert "Firewall" in result["errors"]
mock_nginx.assert_called_once()
def test_force_body_forwarded_to_firewall_only(self):
mock_fw = MagicMock()
mock_nginx = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["nginx"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"firewall": mock_fw,
"nginx": mock_nginx,
},
),
):
status.status_apply_all(None, {"force": True})
mock_fw.assert_called_once_with(None, {"force": True})
mock_nginx.assert_called_once_with(None, None)
def test_no_body_passed_without_force(self):
mock_fw = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
):
status.status_apply_all(None, None)
mock_fw.assert_called_once_with(None, None)
class TestSysOrder:
"""Verify SYS_ORDER and SYS_LABELS constants."""
+10 -2
View File
@@ -226,10 +226,14 @@ class TestGetAffected:
class TestDnsToFirewallSync:
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
@patch("lib.dnsmasq.save_config")
@patch("lib.firewall.save_config")
@patch("lib.firewall.get_config")
@patch("lib.dnsmasq.get_config")
def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save):
def test_adds_dhcp_dns(
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
):
mock_dm_get.return_value = {
"dhcp": {
"ranges": [
@@ -288,10 +292,14 @@ class TestDnsToFirewallSync:
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
@patch("lib.dnsmasq.save_config")
@patch("lib.firewall.save_config")
@patch("lib.firewall.get_config")
@patch("lib.dnsmasq.get_config")
def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save):
def test_idempotent(
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
):
mock_dm_get.return_value = {
"dhcp": {
"ranges": [
+84 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
from lib import system_import
from lib.common import save_json
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, save_json
@pytest.fixture
@@ -137,6 +137,39 @@ class TestImportDnsmasq:
):
assert not system_import.import_dnsmasq()
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
# Existing config differs from the live conf and carries apply
# bookkeeping — the rewrite must keep the baseline so pending
# detection and cancel-all survive daemon restarts.
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
cfg_path = tmp_path / "config" / "dnsmasq"
cfg_path.mkdir(parents=True, exist_ok=True)
baseline = {"dns": {"upstreams": ["1.1.1.1"]}}
save_json(
cfg_path / "config.json",
{
**baseline,
_APPLY_HASH_KEY: "old-hash",
_LAST_APPLIED_CONFIG_KEY: baseline,
},
)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == "old-hash"
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
assert cfg["dns"]["upstreams"] == ["8.8.8.8"]
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
# No config file yet: the imported content is the running state,
# so it must be stamped as applied (no phantom pending changes).
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["dns"]["upstreams"] == ["8.8.8.8"]
# ──────────────────────────────────────────────────────────────────────
# WireGuard
@@ -232,6 +265,44 @@ class TestImportWireguard:
assert system_import.import_wireguard()
assert not system_import.import_wireguard()
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
cfg_path = tmp_path / "config" / "wireguard"
cfg_path.mkdir(parents=True, exist_ok=True)
baseline = {"interface": {"listen_port": 51821}, "peers": {}}
save_json(
cfg_path / "config.json",
{
**baseline,
_APPLY_HASH_KEY: "old-hash",
_LAST_APPLIED_CONFIG_KEY: baseline,
},
)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == "old-hash"
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
assert cfg["interface"]["listen_port"] == 51820
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["interface"]["private_key"] == "abc123"
# ──────────────────────────────────────────────────────────────────────
# Networkd
@@ -593,6 +664,18 @@ class TestImportFirewall:
cfg = self._read_json(tmp_path)
assert "dmz" not in cfg["zones"]
def test_import_stamps_applied(self, temp_project, tmp_path):
# Fresh import adopts the live firewalld state, which is by
# definition the applied state — the file must carry a baseline.
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["zones"]["public"]["interfaces"] == [
"eth0",
"eth1",
]
def test_parse_error_returns_false(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
assert not system_import.import_firewall()
+38 -2
View File
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
" listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show_output(output)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self):
result = wireguard._parse_wg_show_output("")
result = wireguard.parse_wg_show_output("")
assert result["up"] is False
assert result["peers"] == []
def test_parses_fwmark(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" fwmark: 0x0\n"
)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["fwmark"] == "0x0"
def test_peer_transfer_and_keepalive(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" peer: PUBKEY1\n"
" endpoint: 203.0.113.1:51820\n"
" allowed ips: 10.0.0.0/24, 10.0.1.0/24\n"
" latest handshake: 2 minutes ago\n"
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
" persistent-keepalive: 25\n"
)
result = wireguard.parse_wg_show_output(output)
peer = result["peers"][0]
assert peer["allowed_ips"] == ["10.0.0.0/24", "10.0.1.0/24"]
assert peer["latest_handshake"] == "2 minutes ago"
assert peer["transfer_received"] == "1.23 GiB received"
assert peer["transfer_sent"] == "4.56 GiB sent"
assert peer["persistent_keepalive"] == 25
def test_bad_keepalive_value(self):
output = "interface: wg0\n peer: PUBKEY1\n persistent-keepalive: bogus\n"
result = wireguard.parse_wg_show_output(output)
assert result["peers"][0]["persistent_keepalive"] is None
class TestAccessClasses:
def test_default_config_has_access_classes(self):
+75 -239
View File
@@ -3,11 +3,11 @@
Exposed at /api/certs/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
from daemon.client import delete, get, post # noqa: F401 (resolved via module globals)
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE,
@@ -22,269 +22,105 @@ from daemon.iface import (
POST_ACME_RENEW,
POST_ACME_VALIDATE,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("certs", __name__)
@bp.route("/list", methods=["GET"])
def list_certs_bp():
"""GET /api/certs/list — list all managed ACME certificates.
Returns:
Response containing the list of certificates or an error message.
"""
try:
return _ok(get(GET_ACME_LIST))
except RuntimeError as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500)
def _validate_body(request: Any, _va: Any) -> dict[str, Any]:
domain = ((request.get_json(silent=True) or {}).get("domain") or "").strip()
if not domain:
raise ValueError("'domain' is required")
return {"domain": domain}
@bp.route("/<domain>", methods=["GET"])
def cert_details(domain: str):
"""GET /api/certs/<domain> — get details for a specific certificate.
Args:
domain: Domain name to look up.
Returns:
Response containing certificate info or an error message.
"""
try:
return _ok(get(GET_ACME_INFO, {"domain": domain}))
except NotFound as exc:
logger.info("Cert for '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/validate", methods=["POST"])
def validate():
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
Expects JSON body with ``{``domain``}``.
Returns:
Response containing validation results or an error message.
"""
def _issue_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
result = post(POST_ACME_VALIDATE, {"domain": domain})
return _ok(result)
except BadRequest as exc:
logger.info("Validation rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to validate cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/start", methods=["POST"])
def issue_start():
"""POST /api/certs/issue/start — create a new certificate issuance request.
Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``.
Returns:
Response containing an issuance request ID or an error message.
"""
body = request.get_json(silent=True) or {}
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
raise ValueError("'domain' is required")
email = (body.get("email") or "").strip() or None
webroot = body.get("webroot")
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
result = post(
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
)
logger.info(
"Certificate issuance started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except Conflict as exc:
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
return {"domain": domain, "webroot": body.get("webroot"), "email": email}
@bp.route("/issue/<request_id>", methods=["GET"])
def issue_status(request_id: str):
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request.
Args:
request_id: Issuance request identifier returned by issue_start.
Returns:
Response containing issuance status or an error message.
"""
try:
result = get(GET_ACME_ISSUE_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Issuance request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
def _email_body(request: Any, _va: Any) -> dict[str, Any]:
email = ((request.get_json(silent=True) or {}).get("email") or "").strip()
if not email:
raise ValueError("'email' is required")
return {"email": email}
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
Returns:
Response containing a renewal request ID (poll it at
``/api/certs/renew/<request_id>``) or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
result = post(POST_ACME_RENEW, {"domain": domain})
logger.info(
"Certificate renewal started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to renew cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
def _register_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
raise ValueError("'email' is required")
return {"email": email, "server": (body.get("server") or "").strip()}
@bp.route("/renew/<request_id>", methods=["GET"])
def renew_status(request_id: str):
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
Args:
request_id: Renewal request identifier returned by renew_bp.
Returns:
Response containing renewal status or an error message.
"""
try:
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Renewal request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
def _email_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"email": sent["email"]}
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
Args:
domain: Domain name whose certificate should be removed.
Returns:
Response confirming removal or an error message.
"""
try:
delete(DELETE_ACME_REMOVE, {"domain": domain})
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None)
except NotFound as exc:
logger.info("Cert '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500)
@daemon_route(GET_ACME_LIST, bp)
def list_certs_bp():
"""GET /api/certs/list — List all managed ACME certificates."""
@bp.route("/email", methods=["POST"])
@daemon_route(GET_ACME_INFO, bp, rule="/<domain>")
def cert_details():
"""GET /api/certs/<domain> — Get details for a specific certificate."""
@daemon_route(POST_ACME_VALIDATE, bp, body=_validate_body)
def validate():
"""POST /api/certs/validate — Run pre-flight checks for issuance."""
@daemon_route(POST_ACME_ISSUE, bp, rule="/issue/start", body=_issue_body)
def issue_start():
"""POST /api/certs/issue/start — Create a new certificate issuance request."""
@daemon_route(
GET_ACME_ISSUE_STATUS, bp, rule="/issue/<request_id>", params={"id": "request_id"}
)
def issue_status():
"""GET /api/certs/issue/<request_id> — Poll status of an issuance request."""
@daemon_route(POST_ACME_RENEW, bp, rule="/<domain>/renew")
def renew_bp():
"""POST /api/certs/<domain>/renew — Start an (async) certificate renewal."""
@daemon_route(
GET_ACME_RENEW_STATUS, bp, rule="/renew/<request_id>", params={"id": "request_id"}
)
def renew_status():
"""GET /api/certs/renew/<request_id> — Poll status of a certificate renewal."""
@daemon_route(DELETE_ACME_REMOVE, bp, rule="/<domain>", transform=void_transform)
def remove_bp():
"""DELETE /api/certs/<domain> — Remove a certificate from ACME management."""
@daemon_route(POST_ACME_EMAIL, bp, body=_email_body, transform=_email_echo)
def set_email_bp():
"""POST /api/certs/email — set the ACME account email address.
Expects JSON body with ``{``email``}``.
Returns:
Response confirming the email was set or an error message.
"""
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
try:
post(POST_ACME_EMAIL, {"email": email})
logger.info("ACME email set via API: %s", email)
return _ok({"email": email})
except BadRequest as exc:
logger.info("ACME email set rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500)
"""POST /api/certs/email — Set the ACME account email address."""
@bp.route("/account", methods=["GET"])
@daemon_route(GET_ACME_ACCOUNT, bp)
def account():
"""GET /api/certs/account — return ACME account information.
Returns:
Response containing account status or an error message.
"""
try:
result = get(GET_ACME_ACCOUNT)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to get ACME account: %s", exc)
return _error(str(exc), 500)
"""GET /api/certs/account — Return ACME account information."""
@bp.route("/account/register", methods=["POST"])
@daemon_route(POST_ACME_ACCOUNT_REGISTER, bp, body=_register_body)
def register_account():
"""POST /api/certs/account/register — register a new ACME account.
Expects JSON body with ``{``email``, ``server``?}``.
Returns:
Response confirming registration or an error message.
"""
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
server = (body.get("server") or "").strip()
try:
result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server})
return _ok(result)
except BadRequest as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to register ACME account: %s", exc)
return _error(str(exc), 500)
"""POST /api/certs/account/register — Register a new ACME account."""
@bp.route("/account", methods=["DELETE"])
@daemon_route(DELETE_ACME_ACCOUNT_DEACTIVATE, bp, rule="/account", body=NO_BODY)
def deactivate_account():
"""DELETE /api/certs/account — deactivate the ACME account.
Returns:
Response confirming deactivation or an error message.
"""
try:
result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to deactivate ACME account: %s", exc)
return _error(str(exc), 500)
"""DELETE /api/certs/account — Deactivate the ACME account."""
+185 -6
View File
@@ -1,14 +1,36 @@
"""Shared API response helpers.
"""Shared API response helpers + daemon-proxy route factory.
Used by all API blueprints to produce consistent JSON responses
per the API response contract: ``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``.
Used by all API blueprints to produce consistent JSON responses per the
API response contract (``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``) and to collapse the repetitive
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
single declarative ``daemon_route`` decorator.
The factory dispatches to the ``daemon.client`` verb imported into the
blueprint's own module namespace (resolved via ``sys.modules`` at request
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
"""
from flask import jsonify
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from flask import Blueprint, jsonify, request
from daemon.client import BadRequest, Conflict, NotFound
from daemon.iface import Endpoint
logger = logging.getLogger(__name__)
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
NO_BODY = object()
Verb = Callable[..., Any]
def _ok(data=None):
def _ok(data: Any = None):
"""Return a success JSON response."""
return jsonify({"ok": True, "data": data})
@@ -16,3 +38,160 @@ def _ok(data=None):
def _error(msg: str, code: int = 400):
"""Return an error JSON response with the given HTTP status code."""
return jsonify({"ok": False, "error": msg}), code
def _derive_rule(path: str) -> str:
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
daemon endpoint path by dropping the leading subsystem segment.
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
``/issue/status``.
"""
parts = path.lstrip("/").split("/")
if len(parts) <= 1:
return "/"
return "/" + "/".join(parts[1:])
def _map_view_args(
view_args: dict[str, Any], params: dict[str, str] | None
) -> dict[str, Any]:
"""Map Flask view args onto daemon body keys.
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
not listed maps to itself (identity), so path params are always
forwarded and only renamed where the daemon expects a different key.
"""
result = dict(view_args)
for body_key, view_arg_name in (params or {}).items():
result.pop(view_arg_name, None)
result[body_key] = view_args[view_arg_name]
return result
def daemon_route(
endpoint: Endpoint,
bp: Blueprint,
rule: str | None = None,
methods: tuple[str, ...] | list[str] | None = None,
*,
params: dict[str, str] | None = None,
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
body: Any | None = None,
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
) -> Callable[..., Any]:
"""Decorator factory for thin daemon-proxy routes.
Args:
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
is the daemon HTTP verb.
bp: The target blueprint.
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
to the endpoint path minus its leading subsystem segment.
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
override where the UI verb differs from the daemon verb
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
params: ``{body_key: view_arg_name}`` renames for path params.
precheck: ``(json, view_args) -> None`` run before dispatch; raise
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
validation the daemon does not perform). ``json`` is the raw
``request.get_json(silent=True)`` result.
body: How to build the daemon request body for non-GET verbs.
``None`` (default): ``{**json, **mapped_view_args}``;
``NO_BODY``: send no body argument;
a callable ``(request, view_args) -> dict``: custom body (raise
``ValueError`` for 400);
a ``dict``: fixed body (merged with mapped view args).
transform: ``(data, view_args, sent_body) -> data`` applied to the
daemon result before wrapping in ``_ok()``; may raise a typed
exception to emit an error (e.g. 400 when a result is invalid).
Returns:
A decorator that registers the route and returns a view function
whose ``__name__``/``__doc__`` are inherited from the decorated
function.
"""
if rule is None:
rule = _derive_rule(endpoint[1])
if methods is None:
methods = [endpoint[0]]
daemon_method = endpoint[0]
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
# The decorated view lives in the blueprint's module; its ``__globals__``
# is that module's namespace. Resolving the daemon verb here (instead of
# ``daemon.client`` directly) means tests patching
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
module_globals = fn.__globals__
def view(**view_args: Any) -> Any:
try:
json: Any = request.get_json(silent=True)
if precheck is not None:
precheck(json, view_args)
mapped = _map_view_args(view_args, params)
verb_fn: Verb = module_globals[daemon_method.lower()]
if daemon_method == "GET":
sent_body = mapped
data = (
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
)
elif body is NO_BODY:
sent_body = None
data = verb_fn(endpoint)
elif callable(body):
built = body(request, view_args)
sent_body = built
data = (
verb_fn(endpoint, built)
if built is not None
else verb_fn(endpoint)
)
elif isinstance(body, dict):
sent_body = {**body, **mapped}
data = verb_fn(endpoint, sent_body)
else:
base = json if isinstance(json, dict) else {}
sent_body = {**base, **mapped}
data = verb_fn(endpoint, sent_body)
if transform is not None:
data = transform(data, view_args, sent_body)
return _ok(data)
except (BadRequest, ValueError) as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error(
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
)
return _error(str(exc), 500)
view.__name__ = fn.__name__
view.__doc__ = fn.__doc__
bp.add_url_rule(rule, view_func=view, methods=list(methods))
return view
return decorator
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
"""Precheck: reject a non-dict JSON body (400).
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
"""
if json is not None and not isinstance(json, dict):
raise ValueError("Request body must be a JSON object")
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
"""Transform: discard the daemon result and return ``data: null``.
Matches routes that historically responded ``_ok(None)`` (the daemon
result was intentionally ignored by the caller).
"""
return None
+137 -270
View File
@@ -3,11 +3,16 @@
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
@@ -23,252 +28,139 @@ from daemon.iface import (
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("dhcp", __name__)
# ---------------------------------------------------------------------------
# Config
# Config / status
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
@daemon_route(GET_DNSMASQ_CONFIG, bp)
def get_config_bp():
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration.
Returns:
JSON response with the config or an error.
"""
try:
return _ok(get(GET_DNSMASQ_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read DHCP config: %s", exc)
return _error(str(exc), 500)
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config():
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration.
Args:
request: JSON body containing the complete config object.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save DHCP config: %s", exc)
return _error(str(exc), 500)
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config():
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration.
Args:
request: JSON body containing the fields to update.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch DHCP config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration."""
@bp.route("/apply", methods=["POST"])
@daemon_route(POST_DNSMASQ_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
try:
post(POST_DNSMASQ_APPLY)
logger.info("dnsmasq config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply dnsmasq config: %s", exc)
return _error(str(exc), 500)
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration."""
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
@daemon_route(GET_DNSMASQ_STATUS, bp)
def status_bp():
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
try:
return _ok(get(GET_DNSMASQ_STATUS))
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DHCP ranges
# ---------------------------------------------------------------------------
@bp.route("/ranges", methods=["POST"])
def _add_range_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
iface = (body.get("interface") or "").strip() or None
start = (body.get("start") or "").strip()
end = (body.get("end") or "").strip()
if not start or not end:
raise ValueError("'start' and 'end' are required")
return {
"interface": iface or "",
"start": start,
"end": end,
"lease_time": body.get("lease_time", "12h"),
}
def _remove_range_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
iface = (body.get("interface") or "").strip() or ""
start = (body.get("start") or "").strip()
end = (body.get("end") or "").strip()
if not start or not end:
raise ValueError("'start' and 'end' are required")
return {"interface": iface, "start": start, "end": end}
@daemon_route(
POST_DNSMASQ_RANGES_ADD,
bp,
rule="/ranges",
body=_add_range_body,
transform=void_transform,
)
def add_range_bp():
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
Args:
request: JSON body with `interface`, `start`, `end`, and optional `lease_time`.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or None
start = body.get("start", "").strip()
end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h")
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
post(
POST_DNSMASQ_RANGES_ADD,
{
"interface": iface or "",
"start": start,
"end": end,
"lease_time": lease_time,
},
)
logger.info("DHCP range added via API: %s-%s", start, end)
return _ok(None)
except BadRequest as exc:
logger.info("Add DHCP range rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add DHCP range: %s", exc)
return _error(str(exc), 500)
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface."""
@bp.route("/ranges", methods=["DELETE"])
@daemon_route(
DELETE_DNSMASQ_RANGES_REMOVE,
bp,
rule="/ranges",
body=_remove_range_body,
transform=void_transform,
)
def remove_range_bp():
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
Args:
request: JSON body with `interface`, `start`, and `end`.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
end = body.get("end", "").strip()
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
delete(
DELETE_DNSMASQ_RANGES_REMOVE,
{"interface": iface, "start": start, "end": end},
)
logger.info("DHCP range removed via API: %s-%s", start, end)
return _ok(None)
except NotFound as exc:
logger.info("Remove DHCP range not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove DHCP range: %s", exc)
return _error(str(exc), 500)
"""DELETE /api/dhcp/ranges — Remove a DHCP address range."""
# ---------------------------------------------------------------------------
# Leases
# ---------------------------------------------------------------------------
@bp.route("/leases", methods=["GET"])
@daemon_route(GET_DNSMASQ_LEASES, bp)
def leases_bp():
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
try:
return _ok(get(GET_DNSMASQ_LEASES))
except RuntimeError as exc:
logger.error("Failed to read lease table: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Static leases
# ---------------------------------------------------------------------------
@bp.route("/static-lease", methods=["POST"])
def add_static_lease_bp():
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address.
Args:
request: JSON body with `mac`, `ip`, and optional `hostname`.
Returns:
JSON response with lease details or an error.
"""
def _add_static_lease_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
mac = body.get("mac", "").strip()
ip = body.get("ip", "").strip()
hostname = body.get("hostname")
mac = (body.get("mac") or "").strip()
ip = (body.get("ip") or "").strip()
if not mac or not ip:
return _error("'mac' and 'ip' are required", 400)
try:
post(
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
)
logger.info("Static lease added via API: %s -> %s", mac, ip)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except BadRequest as exc:
logger.info("Add static lease rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add static lease: %s", exc)
return _error(str(exc), 500)
raise ValueError("'mac' and 'ip' are required")
return {"mac": mac, "ip": ip, "hostname": body.get("hostname")}
@bp.route("/static-lease/<mac>", methods=["DELETE"])
def remove_static_lease_bp(mac):
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
def _static_lease_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"mac": sent["mac"], "ip": sent["ip"], "hostname": sent["hostname"]}
Args:
mac: MAC address of the static lease to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
logger.info("Static lease removed via API: %s", mac)
return _ok(None)
except NotFound as exc:
logger.info("Static lease '%s' not found: %s", mac, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove static lease '%s': %s", mac, exc)
return _error(str(exc), 500)
@daemon_route(
POST_DNSMASQ_STATIC_LEASE_ADD,
bp,
rule="/static-lease",
body=_add_static_lease_body,
transform=_static_lease_echo,
)
def add_static_lease_bp():
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address."""
@daemon_route(
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
bp,
rule="/static-lease/<mac>",
transform=void_transform,
)
def remove_static_lease_bp():
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC."""
# ---------------------------------------------------------------------------
@@ -276,35 +168,42 @@ def remove_static_lease_bp(mac):
# ---------------------------------------------------------------------------
@bp.route("/dns-record", methods=["POST"])
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record.
Args:
request: JSON body with `name`, `address`, and optional `hostname`.
Returns:
JSON response with record details or an error.
"""
def _add_dns_record_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
address = body.get("address", "").strip()
hostname = body.get("hostname")
name = (body.get("name") or "").strip()
address = (body.get("address") or "").strip()
if not name or not address:
return _error("'name' and 'address' are required", 400)
try:
post(
POST_DNSMASQ_DNS_RECORD_ADD,
{"name": name, "address": address, "hostname": hostname},
)
logger.info("DNS record added via API: %s -> %s", name, address)
return _ok({"name": name, "address": address, "hostname": hostname})
except BadRequest as exc:
logger.info("Add DNS record rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add DNS record: %s", exc)
return _error(str(exc), 500)
raise ValueError("'name' and 'address' are required")
return {"name": name, "address": address, "hostname": body.get("hostname")}
def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {
"name": sent["name"],
"address": sent["address"],
"hostname": sent["hostname"],
}
@daemon_route(
POST_DNSMASQ_DNS_RECORD_ADD,
bp,
rule="/dns-record",
body=_add_dns_record_body,
transform=_dns_record_echo,
)
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record."""
@daemon_route(
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
bp,
rule="/dns-record/<name>",
transform=void_transform,
)
def remove_dns_record_bp():
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name."""
# ---------------------------------------------------------------------------
@@ -312,48 +211,16 @@ def add_dns_record_bp():
# ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"])
def _set_domain_body(request: Any, _va: Any) -> dict[str, Any]:
return {"domain": (request.get_json(silent=True) or {}).get("domain")}
@daemon_route(
POST_DNSMASQ_DOMAIN,
bp,
precheck=require_dict_body,
body=_set_domain_body,
transform=void_transform,
)
def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
Args:
name: Name of the DNS record to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
logger.info("DNS record removed via API: %s", name)
return _ok(None)
except NotFound as exc:
logger.info("DNS record '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove DNS record '%s': %s", name, exc)
return _error(str(exc), 500)
"""POST /api/dhcp/domain — Set or clear the DNS search domain."""
+249 -524
View File
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -30,169 +36,179 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__)
# ---------------------------------------------------------------------------
# Body builders / prechecks / transforms
# ---------------------------------------------------------------------------
def _config_save_precheck(json: Any, _va: Any) -> None:
body = json or {}
if "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
def _interfaces_precheck(json: Any, _va: Any) -> None:
if not isinstance((json or {}).get("interfaces", []), list):
raise ValueError("'interfaces' must be a list")
def _services_precheck(json: Any, _va: Any) -> None:
if not isinstance((json or {}).get("services", []), list):
raise ValueError("'services' must be a list")
def _pending_data() -> dict[str, Any] | None:
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
return {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
# The save already succeeded; the follow-up read is best-effort so a
# failure degrades to a bare ``config_saved`` rather than a 500.
logger.warning("Failed to read pending state after config save: %s", exc)
return None
def _config_saved(_data: Any, _va: Any, _sent: Any) -> Any:
return {"config_saved": True, **(_pending_data() or {})}
def _create_zone_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone_name = (body.get("name") or "").strip()
if not zone_name:
raise ValueError("Zone name is required")
target = (body.get("target") or "").strip() or "default"
return {"name": zone_name, "target": target}
def _zones_list(data: Any, _va: Any, _sent: Any) -> Any:
return {"active": data.get("active", {}), "available": data.get("available", [])}
def _zone_interfaces_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "interfaces": sent.get("interfaces", [])}
def _zone_services_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "services": sent.get("services", [])}
def _add_rich_rule_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
rule = (body.get("rule") or "").strip()
if not zone or not rule:
raise ValueError("Both 'zone' and 'rule' are required")
return {"zone": zone, "rule": rule}
def _rich_rule_add_echo(data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "id": data["id"], "rule": sent["rule"]}
def _rich_rule_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "id": sent["id"]}
def _masquerade_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
enable = body.get("enable")
if not zone or enable is None:
raise ValueError("'zone' and 'enable' (bool) are required")
return {"zone": zone, "enable": bool(enable)}
def _masquerade_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "masquerade": sent["enable"]}
def _add_forward_port_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
port = body.get("port")
proto = (body.get("proto") or "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required")
try:
port_int = int(port)
except ValueError:
raise ValueError("'port' must be an integer") from None
toport_int = None
if toport is not None:
try:
toport_int = int(toport)
except ValueError:
raise ValueError("'toport' must be an integer") from None
return {
"zone": zone,
"port": port_int,
"proto": proto,
"toaddr": str(toaddr) if toaddr else None,
"toport": toport_int,
}
def _forward_port_add_echo(data: Any, _va: Any, sent: Any) -> Any:
return {
"zone": sent["zone"],
"id": data["id"],
"port": sent["port"],
"proto": sent["proto"],
}
def _forward_port_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "port": sent["port"], "proto": sent["proto"]}
# ---------------------------------------------------------------------------
# Declarative config (two-step: save -> apply)
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG, bp)
def config_list():
"""Retrieve the current firewall declarative configuration.
Returns JSON containing the full firewall config from the daemon.
Endpoint:
GET /api/firewall/config
Returns:
JSON response with the config data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config — Retrieve the current firewall config."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
)
def config_save():
"""Save a new firewall declarative configuration.
Validates that the request body contains a ``zones`` dict, forwards
to the daemon, and returns the pending state including unmanaged zones.
Endpoint:
POST /api/firewall/config
Args:
body: JSON with ``zones`` dict mapping zone names to zone configs.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if "zones" not in body:
return _error("'zones' key is required", 400)
if not isinstance(body["zones"], dict):
return _error("'zones' must be a dict", 400)
try:
post(POST_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config save: %s", exc)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config — Save a new firewall declarative configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
)
def patch_config():
"""Partially update the firewall declarative configuration.
Accepts a JSON body and forwards it as a patch to the daemon config
endpoint, returning the updated pending state.
Endpoint:
PATCH /api/firewall/config
Args:
body: JSON object with configuration fields to patch.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config patch: %s", exc)
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch firewall config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/firewall/config — Partially update the firewall configuration."""
@bp.route("/config/apply", methods=["POST"])
@daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
def config_apply_bp():
"""Apply any pending firewall configuration changes.
Triggers the daemon to apply saved declarative config to the live
firewalld instance.
Endpoint:
POST /api/firewall/config/apply
Returns:
JSON with ``applied_zones`` list or an error message.
"""
try:
result = post(POST_FIREWALL_CONFIG_APPLY)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config/apply — Apply pending firewall config changes."""
@bp.route("/config/pending", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
def config_pending_bp():
"""Check the pending firewall configuration state.
Returns information about unsaved changes, whether an apply is
needed, and any unmanaged zones detected on the system.
Endpoint:
GET /api/firewall/config/pending
Returns:
JSON with pending changes and apply status.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config/pending — Check the pending firewall config state."""
# ---------------------------------------------------------------------------
@@ -200,21 +216,9 @@ def config_pending_bp():
# ---------------------------------------------------------------------------
@bp.route("/state", methods=["GET"])
@daemon_route(GET_FIREWALL_STATE, bp)
def get_state():
"""Retrieve current firewall state from the state store.
Endpoint:
GET /api/firewall/state
Returns:
JSON with firewall state data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_STATE))
except RuntimeError as exc:
logger.error("Failed to get firewall state: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/state — Retrieve current firewall state."""
# ---------------------------------------------------------------------------
@@ -222,182 +226,67 @@ def get_state():
# ---------------------------------------------------------------------------
@bp.route("/zones", methods=["GET"])
@daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
def list_zones():
"""List all active and available firewall zones.
Endpoint:
GET /api/firewall/zones
Returns:
JSON with ``active`` zones dict and ``available`` zones list.
"""
try:
data = get(GET_FIREWALL_ZONES)
return _ok(
{"active": data.get("active", {}), "available": data.get("available", [])}
)
except RuntimeError as exc:
logger.error("Failed to list zones: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/zones — List active and available firewall zones."""
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name: str):
"""Retrieve details for a specific firewall zone.
Endpoint:
GET /api/firewall/zones/<name>
Args:
name: Name of the zone to look up.
Returns:
JSON with zone configuration details or 404 error.
"""
try:
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
return _ok(info)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
)
def zone_details():
"""GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
@bp.route("/zones", methods=["POST"])
@daemon_route(
POST_FIREWALL_ZONES_CREATE,
bp,
rule="/zones",
body=_create_zone_body,
transform=void_transform,
)
def create_zone_bp():
"""Create a new firewall zone.
Endpoint:
POST /api/firewall/zones
Args:
body: JSON with ``name`` (required) and optional ``target`` string.
Returns:
JSON confirmation or error if the zone already exists.
"""
body = request.get_json(silent=True) or {}
zone_name = body.get("name", "").strip()
target = body.get("target", "default").strip() or "default"
if not zone_name:
return _error("Zone name is required", 400)
try:
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
logger.info("Zone '%s' created via API", zone_name)
return _ok(None)
except BadRequest as exc:
logger.info("Zone '%s' creation rejected: %s", zone_name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to create zone '%s': %s", zone_name, exc)
return _error(str(exc), 500)
"""POST /api/firewall/zones — Create a new firewall zone."""
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name: str):
"""Delete a firewall zone by name.
Endpoint:
DELETE /api/firewall/zones/<name>
Args:
name: Name of the zone to delete.
Returns:
JSON confirmation or 404 if the zone does not exist.
"""
try:
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
logger.info("Zone '%s' deleted via API", name)
return _ok(None)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to delete zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_ZONES_DELETE,
bp,
rule="/zones/<name>",
params={"zone": "name"},
transform=void_transform,
)
def delete_zone_bp():
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
# ---------------------------------------------------------------------------
# Zone interfaces
# Zone interfaces / services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name: str):
"""Set the network interfaces assigned to a firewall zone.
Replaces all existing interfaces for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/interfaces
Args:
name: Zone name.
body: JSON with ``interfaces`` list of interface names.
Returns:
JSON confirmation with zone and updated interfaces list.
"""
body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list):
return _error("'interfaces' must be a list", 400)
try:
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except BadRequest as exc:
logger.info("Set interfaces for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_INTERFACES,
bp,
rule="/zones/<name>/interfaces",
params={"zone": "name"},
precheck=_interfaces_precheck,
transform=_zone_interfaces_echo,
)
def set_zone_interfaces_bp():
"""POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
# ---------------------------------------------------------------------------
# Zone services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name: str):
"""Set the allowed services for a firewall zone.
Replaces all existing services for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/services
Args:
name: Zone name.
body: JSON with ``services`` list of service names.
Returns:
JSON confirmation with zone and updated services list.
"""
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
return _error("'services' must be a list", 400)
try:
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
return _ok({"zone": name, "services": services})
except BadRequest as exc:
logger.info("Set services for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_SERVICES,
bp,
rule="/zones/<name>/services",
params={"zone": "name"},
precheck=_services_precheck,
transform=_zone_services_echo,
)
def set_zone_services_bp():
"""POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
# ---------------------------------------------------------------------------
@@ -405,38 +294,14 @@ def set_zone_services_bp(name: str):
# ---------------------------------------------------------------------------
@bp.route("/services", methods=["GET"])
@daemon_route(GET_FIREWALL_SERVICES, bp)
def list_services():
"""List all available firewall services.
Endpoint:
GET /api/firewall/services
Returns:
JSON with the list of available service names.
"""
try:
return _ok(get(GET_FIREWALL_SERVICES))
except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/services — List all available firewall services."""
@bp.route("/interfaces", methods=["GET"])
@daemon_route(GET_FIREWALL_INTERFACES, bp)
def list_interfaces():
"""List all available network interfaces.
Endpoint:
GET /api/firewall/interfaces
Returns:
JSON with the list of available interface names.
"""
try:
return _ok(get(GET_FIREWALL_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/interfaces — List all available network interfaces."""
# ---------------------------------------------------------------------------
@@ -444,80 +309,31 @@ def list_interfaces():
# ---------------------------------------------------------------------------
@bp.route("/rich-rules", methods=["POST"])
@daemon_route(
POST_FIREWALL_RICH_RULES_ADD,
bp,
rule="/rich-rules",
body=_add_rich_rule_body,
transform=_rich_rule_add_echo,
)
def add_rich_rule_bp():
"""Add a rich rule to a firewall zone.
Endpoint:
POST /api/firewall/rich-rules
Args:
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
Returns:
JSON with zone, generated rule ID, and rule string.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
except BadRequest as exc:
logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/rich-rules — Add a rich rule to a firewall zone."""
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone: str):
"""List rich rules for a specific firewall zone.
Endpoint:
GET /api/firewall/rich-rules/<zone>
Args:
zone: Zone name to list rules for.
Returns:
JSON with list of rich rule entries for the zone.
"""
try:
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
def list_rich_rules():
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone: str, rule_id: str):
"""Remove a rich rule from a firewall zone by ID.
Endpoint:
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
Args:
zone: Zone name.
rule_id: Rule identifier.
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except NotFound as exc:
logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_RICH_RULES_REMOVE,
bp,
rule="/rich-rules/<zone>/<rule_id>",
params={"id": "rule_id"},
transform=_rich_rule_remove_echo,
)
def remove_rich_rule_bp():
"""DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
# ---------------------------------------------------------------------------
@@ -525,38 +341,11 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
# ---------------------------------------------------------------------------
@bp.route("/masquerade", methods=["POST"])
@daemon_route(
POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo
)
def set_masquerade_bp():
"""Enable or disable masquerade (NAT) on a firewall zone.
Endpoint:
POST /api/firewall/masquerade
Args:
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
Returns:
JSON confirmation with zone and masquerade status.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
enable = body.get("enable")
if not zone or enable is None:
return _error("'zone' and 'enable' (bool) are required", 400)
try:
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
logger.info(
"Masquerade %s on zone '%s' via API",
"enabled" if enable else "disabled",
zone,
)
return _ok({"zone": zone, "masquerade": bool(enable)})
except BadRequest as exc:
logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
# ---------------------------------------------------------------------------
@@ -564,86 +353,22 @@ def set_masquerade_bp():
# ---------------------------------------------------------------------------
@bp.route("/forward-port", methods=["POST"])
@daemon_route(
POST_FIREWALL_FORWARD_PORT_ADD,
bp,
rule="/forward-port",
body=_add_forward_port_body,
transform=_forward_port_add_echo,
)
def add_forward_port_bp():
"""Add a port forwarding rule to a firewall zone.
Endpoint:
POST /api/firewall/forward-port
Args:
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
(tcp/udp), optional ``toaddr`` and ``toport``.
Returns:
JSON confirmation with zone, generated ID, port, and protocol.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try:
port_int = int(port)
except ValueError:
return _error("'port' must be an integer", 400)
toport_int = None
if toport is not None:
try:
toport_int = int(toport)
except ValueError:
return _error("'toport' must be an integer", 400)
toaddr_str = str(toaddr) if toaddr else None
try:
entry = post(
POST_FIREWALL_FORWARD_PORT_ADD,
{
"zone": zone,
"port": port_int,
"proto": proto,
"toaddr": toaddr_str,
"toport": toport_int,
},
)
return _ok({"zone": zone, "id": entry["id"], "port": port_int, "proto": proto})
except BadRequest as exc:
logger.info("Add forward port rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add forward port: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/forward-port — Add a port forwarding rule to a zone."""
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone: str, port: int, proto: str):
"""Remove a port forwarding rule from a firewall zone.
Endpoint:
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
Args:
zone: Zone name.
port: Port number.
proto: Protocol (tcp/udp).
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
{"zone": zone, "port": port, "proto": proto},
)
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
return _ok({"zone": zone, "port": port, "proto": proto})
except NotFound as exc:
logger.info(
"Forward port %s/%s not found in zone '%s': %s", port, proto, zone, exc
)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
bp,
rule="/forward-port/<zone>/<int:port>/<proto>",
transform=_forward_port_remove_echo,
)
def remove_forward_port_bp():
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
+7 -36
View File
@@ -3,11 +3,9 @@
Wraps raw log text in the standard JSON response contract.
"""
import logging
from flask import Blueprint
from daemon.client import NotFound, get
from daemon.client import get # noqa: F401 (resolved via module globals at dispatch)
from daemon.iface import (
GET_LOGS_APP,
GET_LOGS_DNSMASQ,
@@ -15,58 +13,31 @@ from daemon.iface import (
GET_LOGS_NGINX_ACCESS,
GET_LOGS_NGINX_ERROR,
)
from webui.api.common import _error, _ok
from webui.api.common import daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("logs", __name__)
@bp.route("/journal")
@daemon_route(GET_LOGS_JOURNAL, bp)
def journal():
"""GET /api/logs/journal — Return systemd journal log lines."""
try:
return _ok(get(GET_LOGS_JOURNAL))
except RuntimeError:
return _error("error reading journal", 500)
@bp.route("/nginx/access")
@daemon_route(GET_LOGS_NGINX_ACCESS, bp)
def nginx_access():
"""GET /api/logs/nginx/access — Return nginx access log lines."""
try:
return _ok(get(GET_LOGS_NGINX_ACCESS))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
@bp.route("/nginx/error")
@daemon_route(GET_LOGS_NGINX_ERROR, bp)
def nginx_error():
"""GET /api/logs/nginx/error — Return nginx error log lines."""
try:
return _ok(get(GET_LOGS_NGINX_ERROR))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
@bp.route("/dnsmasq")
@daemon_route(GET_LOGS_DNSMASQ, bp)
def dnsmasq():
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
try:
return _ok(get(GET_LOGS_DNSMASQ))
except RuntimeError:
return _error("error reading journal", 500)
@bp.route("/app")
@daemon_route(GET_LOGS_APP, bp)
def app_log():
"""GET /api/logs/app — Return application log lines."""
try:
return _ok(get(GET_LOGS_APP))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
+45 -130
View File
@@ -4,11 +4,14 @@ Exposes /api/network/* and delegates to vacuum-walld for interface
IP configuration via systemd-networkd.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import NotFound, get, post
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
get,
post,
)
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
@@ -19,150 +22,62 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
)
from lib.common import validate_interface_name
from webui.api.common import _error, _ok
from webui.api.common import daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("network", __name__)
@bp.route("/interfaces", methods=["GET"])
def _check_iface(_json: Any, view_args: dict[str, Any]) -> None:
"""Validate the interface name path param (400 on a bad name)."""
validate_interface_name(view_args["name"])
def _applied(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
return {"name": view_args["name"], "applied": True}
def _reloaded(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
return {"name": view_args["name"], "reloaded": True}
@daemon_route(GET_NETWORK_INTERFACES, bp)
def list_interfaces():
"""List all interfaces with their network config and runtime state.
Endpoint:
GET /api/network/interfaces
Returns:
JSON with interface config + runtime state.
"""
try:
return _ok(get(GET_NETWORK_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list network interfaces: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/interfaces — List interfaces with config + runtime state."""
@bp.route("/interfaces/<name>", methods=["GET"])
def get_interface(name: str):
"""Get config + runtime state for a specific interface.
Endpoint:
GET /api/network/interfaces/<name>
Returns:
JSON with interface config and runtime state.
"""
try:
validate_interface_name(name)
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Interface '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(GET_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface)
def get_interface():
"""GET /api/network/interfaces/<name> — Config + runtime state for one interface."""
@bp.route("/interfaces/<name>", methods=["POST"])
def save_interface(name: str):
"""Save and apply network config for an interface.
Endpoint:
POST /api/network/interfaces/<name>
Args:
body: JSON with addresses, gateway, dns, routes.
Returns:
JSON confirmation.
"""
body = {**(request.get_json(silent=True) or {}), "name": name}
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_NAME, body)
logger.info("Interface '%s' config saved", name)
return _ok({"name": name, "applied": True})
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to save interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface, transform=_applied
)
def save_interface():
"""POST /api/network/interfaces/<name> — Save and apply an interface's config."""
@bp.route("/interfaces/<name>/reload", methods=["POST"])
def reload_interface(name: str):
"""Reload networkd for a single interface.
Endpoint:
POST /api/network/interfaces/<name>/reload
Returns:
JSON confirmation.
"""
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
logger.info("Interface '%s' reloaded", name)
return _ok({"name": name, "reloaded": True})
except ValueError as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to reload interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NETWORK_INTERFACE_RELOAD,
bp,
precheck=_check_iface,
body={},
transform=_reloaded,
)
def reload_interface():
"""POST /api/network/interfaces/<name>/reload — Reload networkd for one interface."""
@bp.route("/apply", methods=["POST"])
@daemon_route(POST_NETWORK_APPLY, bp)
def apply_all():
"""Apply network config for ALL interfaces (full sync).
Endpoint:
POST /api/network/apply
Returns:
JSON with number of interfaces applied.
"""
try:
result = post(POST_NETWORK_APPLY, {})
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply network config: %s", exc)
return _error(str(exc), 500)
"""POST /api/network/apply — Apply network config for ALL interfaces."""
@bp.route("/infer-dhcp-ranges", methods=["GET"])
@daemon_route(GET_NETWORK_INFER_DHCP_RANGES, bp)
def infer_dhcp_ranges():
"""Suggest candidate DHCP ranges based on static interface IPs.
Endpoint:
GET /api/network/infer-dhcp-ranges
Returns:
JSON with per-interface suggested DHCP ranges.
"""
try:
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
except RuntimeError as exc:
logger.error("Failed to infer DHCP ranges: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/infer-dhcp-ranges — Suggest candidate DHCP ranges."""
@bp.route("/infer-zones", methods=["GET"])
@daemon_route(GET_NETWORK_INFER_ZONES, bp)
def infer_zones():
"""Suggest firewalld zone assignments for configured interfaces.
Endpoint:
GET /api/network/infer-zones
Returns:
JSON with per-interface suggested zone names.
"""
try:
return _ok(get(GET_NETWORK_INFER_ZONES))
except RuntimeError as exc:
logger.error("Failed to infer zones: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/infer-zones — Suggest firewalld zone assignments."""
+112 -296
View File
@@ -3,11 +3,17 @@
Exposed at /api/proxy/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
BadRequest,
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_NGINX_BACKENDS_REMOVE,
DELETE_NGINX_DOMAINS_REMOVE,
@@ -24,144 +30,59 @@ from daemon.iface import (
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("proxy", __name__)
@bp.route("/ssl-apply", methods=["POST"])
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@daemon_route(POST_NGINX_SSL_APPLY, bp, body=NO_BODY, transform=void_transform)
def ssl_apply_bp():
"""Apply SSL snippet config.
POST /api/proxy/ssl-apply
Returns:
``{"ok": true}`` on success.
Raises:
RuntimeError: If nginx SSL snippet write fails.
"""
try:
post(POST_NGINX_SSL_APPLY)
logger.info("SSL snippet written via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to write SSL snippet: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/ssl-apply — Apply the SSL snippet config."""
@bp.route("/config", methods=["GET"])
@daemon_route(GET_NGINX_CONFIG, bp)
def get_config_bp():
"""Get the current nginx proxy configuration.
GET /api/proxy/config
Returns:
Current config dict from the daemon.
"""
try:
return _ok(get(GET_NGINX_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read proxy config: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/config — Get the current nginx proxy configuration."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config():
"""Save the nginx proxy configuration.
POST /api/proxy/config
Body:
Any JSON object to merge into the config.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_NGINX_CONFIG, body)
logger.info("Proxy config saved: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("Proxy config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save proxy config: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/config — Save the nginx proxy configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config():
"""Partially update the nginx proxy configuration.
PATCH /api/proxy/config
Body:
JSON object with fields to patch.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_NGINX_CONFIG, body)
logger.info("Proxy config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("Proxy config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch proxy config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/proxy/config — Partially update the nginx proxy configuration."""
@bp.route("/domains", methods=["GET"])
@daemon_route(GET_NGINX_DOMAINS, bp)
def list_domains():
"""List all configured proxy domains.
GET /api/proxy/domains
Returns:
List of domain dicts from the daemon.
"""
try:
return _ok(get(GET_NGINX_DOMAINS))
except RuntimeError as exc:
logger.error("Failed to list proxy domains: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/domains — List all configured proxy domains."""
@bp.route("/domains", methods=["POST"])
def add_domain_bp():
"""Add a new proxy domain referencing a backend.
# ---------------------------------------------------------------------------
# Domain CRUD
# ---------------------------------------------------------------------------
POST /api/proxy/domains
Body fields:
domain: Domain name.
backend: Backend name to proxy through.
cert: Optional certificate type.
force_ssl: Optional SSL redirect flag (default ``true``).
auth: Optional domain-level auth override.
Returns:
``{"domain": ...}`` on success.
"""
def _add_domain_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
backend = body.get("backend", "").strip()
raise ValueError("'domain' is required")
backend = (body.get("backend") or "").strip()
if not backend:
return _error("'backend' is required", 400)
payload = {
raise ValueError("'backend' is required")
payload: dict[str, Any] = {
"domain": domain,
"backend": backend,
"force_ssl": body.get("force_ssl", True),
@@ -170,105 +91,62 @@ def add_domain_bp():
payload["cert"] = body["cert"]
if body.get("auth") is not None:
payload["auth"] = body["auth"]
try:
post(POST_NGINX_DOMAINS_ADD, payload)
logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain})
except BadRequest as exc:
logger.info("Add proxy domain '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
return _error(str(exc), 500)
return payload
@bp.route("/domains/<domain>", methods=["PUT"])
def update_domain_bp(domain):
"""Update an existing proxy domain in-place.
PUT /api/proxy/domains/<domain>
Body fields:
Fields to merge into the domain config.
Returns:
``{"domain": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not body:
return _error("Request body must be a JSON object with fields to update", 400)
try:
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain})
except BadRequest as exc:
logger.info("Update domain '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Domain '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update domain '%s': %s", domain, exc)
return _error(str(exc), 500)
def _domain_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"domain": sent.get("domain")}
@bp.route("/domains/<domain>", methods=["DELETE"])
def remove_domain_bp(domain):
"""Remove a proxy domain.
DELETE /api/proxy/domains/<domain>
Returns:
``{"domain": ...}`` on success.
"""
try:
delete(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
logger.info("Proxy domain removed via API: %s", domain)
return _ok({"domain": domain})
except NotFound as exc:
logger.info("Domain '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NGINX_DOMAINS_ADD,
bp,
rule="/domains",
body=_add_domain_body,
transform=_domain_echo,
)
def add_domain_bp():
"""POST /api/proxy/domains — Add a new proxy domain referencing a backend."""
@bp.route("/apply", methods=["POST"])
def _update_domain_precheck(json: Any, _va: Any) -> None:
if not json:
raise ValueError("Request body must be a JSON object with fields to update")
@daemon_route(
POST_NGINX_DOMAINS_UPDATE,
bp,
rule="/domains/<domain>",
methods=["PUT"],
precheck=_update_domain_precheck,
transform=_domain_echo,
)
def update_domain_bp():
"""PUT /api/proxy/domains/<domain> — Update an existing proxy domain in-place."""
@daemon_route(
DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/<domain>", transform=_domain_echo
)
def remove_domain_bp():
"""DELETE /api/proxy/domains/<domain> — Remove a proxy domain."""
@daemon_route(POST_NGINX_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""Generate all nginx configs and reload nginx.
POST /api/proxy/apply
Returns:
``{"ok": true}`` on success.
"""
try:
post(POST_NGINX_APPLY)
logger.info("nginx config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply nginx config: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/apply — Generate all nginx configs and reload nginx."""
@bp.route("/test", methods=["POST"])
def _test_transform(data: Any, _va: Any, _sent: Any) -> Any:
if data.get("valid"):
return {"valid": True, "output": data.get("output", "")}
raise BadRequest(data.get("output", "unknown error"))
@daemon_route(POST_NGINX_TEST, bp, body=NO_BODY, transform=_test_transform)
def test_bp():
"""Test nginx configuration without reloading.
POST /api/proxy/test
Returns:
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
"""
try:
result = post(POST_NGINX_TEST)
if result.get("valid"):
return _ok({"valid": True, "output": result.get("output", "")})
return _error(result.get("output", "unknown error"), 400)
except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/test — Test nginx configuration without reloading."""
# ---------------------------------------------------------------------------
@@ -276,102 +154,40 @@ def test_bp():
# ---------------------------------------------------------------------------
@bp.route("/backends", methods=["GET"])
def _backend_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"backend": sent.get("name")}
@daemon_route(GET_NGINX_BACKENDS, bp)
def list_backends():
"""List all configured backends.
GET /api/proxy/backends
Returns:
Dict of backend configs with secrets stripped.
"""
try:
return _ok(get(GET_NGINX_BACKENDS))
except RuntimeError as exc:
logger.error("Failed to list backends: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/backends — List all configured backends (secrets stripped)."""
@bp.route("/backends", methods=["PATCH"])
@daemon_route(
PATCH_NGINX_BACKENDS, bp, precheck=require_dict_body, transform=_backend_echo
)
def patch_backend_bp():
"""Partially update a backend entry.
PATCH /api/proxy/backends
Body fields:
name: Backend name.
label: Optional new label.
paths: Optional new paths dict.
auth: Optional new auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_NGINX_BACKENDS, body)
logger.info("Backend '%s' patched via API", body.get("name"))
return _ok({"backend": body.get("name")})
except BadRequest as exc:
logger.info("Backend patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch backend: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/proxy/backends — Partially update a backend entry."""
@bp.route("/backends", methods=["POST"])
def _add_backend_precheck(json: Any, _va: Any) -> None:
if not ((json or {}).get("name") or "").strip():
raise ValueError("'name' is required")
@daemon_route(
POST_NGINX_BACKENDS_ADD,
bp,
rule="/backends",
precheck=_add_backend_precheck,
transform=_backend_echo,
)
def add_backend_bp():
"""Add a new backend.
POST /api/proxy/backends
Body fields:
name: Backend name (slug, unique).
label: Human-readable label.
paths: Path-to-config map.
auth: Optional auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("'name' is required", 400)
try:
post(POST_NGINX_BACKENDS_ADD, body)
logger.info("Backend added via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Add backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add backend '%s': %s", name, exc)
return _error(str(exc), 500)
"""POST /api/proxy/backends — Add a new backend."""
@bp.route("/backends/<name>", methods=["DELETE"])
def remove_backend_bp(name):
"""Remove a non-builtin backend.
DELETE /api/proxy/backends/<name>
Returns:
``{"backend": ...}`` on success.
"""
try:
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
logger.info("Backend removed via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Remove backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Remove backend '%s' conflict: %s", name, exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to remove backend '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_NGINX_BACKENDS_REMOVE, bp, rule="/backends/<name>", transform=_backend_echo
)
def remove_backend_bp():
"""DELETE /api/proxy/backends/<name> — Remove a non-builtin backend."""
+16 -79
View File
@@ -3,13 +3,12 @@
Exposed at /api/status/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
from flask import Blueprint
import logging
from flask import Blueprint, request
from daemon.client import get, post
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
get,
post,
)
from daemon.iface import (
GET_STATUS_PENDING,
GET_SYSTEM_METRICS,
@@ -17,93 +16,31 @@ from daemon.iface import (
POST_STATUS_CANCEL_ALL,
POST_STATUS_REFRESH,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"])
@daemon_route(GET_STATUS_PENDING, bp)
def pending():
"""Retrieve aggregate pending changes across all subsystems.
Endpoint:
GET /api/status/pending
Returns:
JSON response with per-subsystem pending status and total change count.
"""
try:
return _ok(get(GET_STATUS_PENDING))
except RuntimeError as exc:
logger.error("Failed to get pending status: %s", exc)
return _error(str(exc), 500)
"""GET /api/status/pending — Per-subsystem pending status + total change count."""
@bp.route("/apply-all", methods=["POST"])
@daemon_route(POST_STATUS_APPLY_ALL, bp)
def apply_all():
"""Apply pending changes for all subsystems in dependency order.
Endpoint:
POST /api/status/apply-all
Returns:
JSON response with applied subsystems list and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_APPLY_ALL))
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/apply-all — Apply pending changes in dependency order."""
@bp.route("/cancel-all", methods=["POST"])
@daemon_route(POST_STATUS_CANCEL_ALL, bp, body=NO_BODY)
def cancel_all():
"""Revert pending changes for all subsystems to the last applied config.
Endpoint:
POST /api/status/cancel-all
Returns:
JSON response with the reverted subsystems, skipped subsystems
(label -> reason), and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_CANCEL_ALL))
except RuntimeError as exc:
logger.error("Failed to cancel all pending changes: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/cancel-all — Revert pending changes to last applied config."""
@bp.route("/refresh", methods=["POST"])
@daemon_route(POST_STATUS_REFRESH, bp)
def refresh():
"""Re-collect state from the daemon, optionally filtered by subsystem.
Endpoint:
POST /api/status/refresh
Body:
{"subsystems": ["firewall"]} or {} for all.
"""
body = request.get_json(silent=True) or {}
try:
return _ok(post(POST_STATUS_REFRESH, body))
except RuntimeError as exc:
logger.error("Failed to refresh state: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/refresh — Re-collect state, optionally filtered by subsystem."""
@bp.route("/system-metrics", methods=["GET"])
@daemon_route(GET_SYSTEM_METRICS, bp, rule="/system-metrics")
def system_metrics():
"""Retrieve system-wide metrics.
Endpoint:
GET /api/status/system-metrics
Returns:
JSON response with CPU load, memory usage, and network traffic stats.
"""
try:
return _ok(get(GET_SYSTEM_METRICS))
except RuntimeError as exc:
logger.error("Failed to get system metrics: %s", exc)
return _error(str(exc), 500)
"""GET /api/status/system-metrics — System-wide CPU/memory/network metrics."""
+215 -436
View File
@@ -3,11 +3,16 @@
Exposed at /api/wireguard/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
@@ -30,473 +35,247 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("wireguard", __name__)
@bp.route("/config", methods=["GET"])
def get_config_bp():
"""Get the current WireGuard configuration.
Endpoint: GET /api/wireguard/config
Returns:
JSON response with the WireGuard config on success, or an error
response on failure.
"""
try:
return _ok(get(GET_WIREGUARD_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read WireGuard config: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Body builders / prechecks / transforms
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["POST"])
def post_config():
"""Create or fully replace the WireGuard configuration.
Endpoint: POST /api/wireguard/config
Args:
body: JSON body with the configuration. If an ``interface`` key
is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
def _wg_config_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
post(POST_WIREGUARD_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("WireGuard config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save WireGuard config: %s", exc)
return _error(str(exc), 500)
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
return body
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""Partially update the WireGuard configuration.
Endpoint: PATCH /api/wireguard/config
Args:
body: JSON body with the fields to update. If an ``interface``
key is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
def _add_peer_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
patch(PATCH_WIREGUARD_CONFIG, body)
logger.info("WireGuard config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("WireGuard config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/apply", methods=["POST"])
def apply_bp():
"""Apply the current WireGuard configuration to the live tunnel.
Endpoint: POST /api/wireguard/apply
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/up", methods=["POST"])
def up_bp():
"""Bring the WireGuard tunnel interface up.
Endpoint: POST /api/wireguard/up
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel started via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to start WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@bp.route("/down", methods=["POST"])
def down_bp():
"""Bring the WireGuard tunnel interface down.
Endpoint: POST /api/wireguard/down
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_DOWN)
logger.info("WireGuard tunnel brought down via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@bp.route("/status", methods=["GET"])
def status_bp():
"""Get the current WireGuard tunnel status.
Endpoint: GET /api/wireguard/status
Returns:
JSON response with the tunnel status on success, or an error
response on failure.
"""
try:
return _ok(get(GET_WIREGUARD_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard status: %s", exc)
return _error(str(exc), 500)
@bp.route("/initialize", methods=["POST"])
def initialize_bp():
"""Initialize WireGuard for first-time use.
Endpoint: POST /api/wireguard/initialize
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_INITIALIZE)
logger.info("WireGuard initialized via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to initialize WireGuard: %s", exc)
return _error(str(exc), 500)
@bp.route("/peers", methods=["POST"])
def add_peer_bp():
"""Add a new peer to the WireGuard configuration.
Endpoint: POST /api/wireguard/peers
Args:
name: Peer display name (required).
endpoint: Optional peer endpoint address.
allowed_ips: Optional list of allowed IP CIDRs.
persistent_keepalive: Optional keepalive interval in seconds.
preshared_key: Optional pre-shared key in hex.
Returns:
JSON response with the created peer on success, 400 on validation
failure, or 500 on server error.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
name = (body.get("name") or "").strip()
if not name:
return _error("'name' is required", 400)
try:
peer = post(
POST_WIREGUARD_PEERS_ADD,
{
"name": name,
"endpoint": body.get("endpoint"),
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description"),
"access_class": body.get("access_class"),
},
)
logger.info("WireGuard peer '%s' added via API", name)
return _ok(peer)
except BadRequest as exc:
logger.info("Add peer '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add peer '%s': %s", name, exc)
return _error(str(exc), 500)
raise ValueError("'name' is required")
return {
"name": name,
"endpoint": body.get("endpoint"),
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description"),
"access_class": body.get("access_class"),
}
@bp.route("/peers/<name>", methods=["DELETE"])
def remove_peer_bp(name):
"""Remove a peer from the WireGuard configuration.
Endpoint: DELETE /api/wireguard/peers/<name>
Args:
name: Peer name to remove (from URL path).
Returns:
Success response with peer name on removal, 404 if peer not found,
or 500 on server error.
"""
try:
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name})
except NotFound as exc:
logger.info("WireGuard peer '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove peer '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/peers", methods=["GET"])
def peers_bp():
"""List all configured WireGuard peers.
Endpoint: GET /api/wireguard/peers
Returns:
JSON response with the peers list on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEERS))
except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500)
@bp.route("/peer-status", methods=["GET"])
def peer_status_bp():
"""Get real-time status information for all WireGuard peers.
Endpoint: GET /api/wireguard/peer-status
Returns:
JSON response with peer status on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEER_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500)
@bp.route("/generate-client", methods=["POST"])
def generate_client_bp():
"""Generate a WireGuard client configuration file for a peer.
Endpoint: POST /api/wireguard/generate-client
Args:
name: Peer name (required).
server_endpoint: Server endpoint address for the client config (required).
Returns:
JSON response with the generated config string on success, 404 if
peer not found, 400 on validation failure, or 500 on server error.
"""
def _gen_client_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
name = (body.get("name") or "").strip()
if not name:
return _error("Field 'name' is required", 400)
raise ValueError("Field 'name' is required")
server_endpoint = body.get("server_endpoint", "")
if not server_endpoint:
return _error("Field 'server_endpoint' is required", 400)
try:
result = post(
POST_WIREGUARD_GENERATE_CLIENT,
{
"name": name,
"server_endpoint": server_endpoint,
},
)
logger.info("Client config generated for peer '%s' via API", name)
return _ok({"config": result.get("config", "")})
except NotFound as exc:
logger.info("Peer '%s' not found for client config: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), 500)
raise ValueError("Field 'server_endpoint' is required")
return {"name": name, "server_endpoint": server_endpoint}
@bp.route("/classes", methods=["GET"])
def _delete_class_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
key = (body.get("key") or "").strip()
if not key:
raise ValueError("'key' is required")
return {"key": key}
def _class_key_precheck(json: Any, va: dict[str, Any]) -> None:
require_dict_body(json, va)
if not ((json or {}).get("key") or "").strip():
raise ValueError("'key' is required")
def _peer_name_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"name": sent["name"]}
def _config_echo(data: Any, _va: Any, _sent: Any) -> Any:
return {"config": data.get("config", "")}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CONFIG, bp)
def get_config_bp():
"""GET /api/wireguard/config — Get the current WireGuard configuration."""
@daemon_route(
POST_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def post_config():
"""POST /api/wireguard/config — Create or fully replace the configuration."""
@daemon_route(
PATCH_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def patch_config():
"""PATCH /api/wireguard/config — Partially update the configuration."""
# ---------------------------------------------------------------------------
# Tunnel control
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""POST /api/wireguard/apply — Apply the current configuration to the tunnel."""
@daemon_route(
POST_WIREGUARD_APPLY, bp, rule="/up", body=NO_BODY, transform=void_transform
)
def up_bp():
"""POST /api/wireguard/up — Bring the WireGuard tunnel interface up."""
@daemon_route(POST_WIREGUARD_DOWN, bp, body=NO_BODY, transform=void_transform)
def down_bp():
"""POST /api/wireguard/down — Bring the WireGuard tunnel interface down."""
@daemon_route(GET_WIREGUARD_STATUS, bp)
def status_bp():
"""GET /api/wireguard/status — Get the current WireGuard tunnel status."""
@daemon_route(POST_WIREGUARD_INITIALIZE, bp, body=NO_BODY, transform=void_transform)
def initialize_bp():
"""POST /api/wireguard/initialize — Initialize WireGuard for first-time use."""
# ---------------------------------------------------------------------------
# Peers
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_PEERS_ADD, bp, rule="/peers", body=_add_peer_body)
def add_peer_bp():
"""POST /api/wireguard/peers — Add a new peer to the configuration."""
@daemon_route(
DELETE_WIREGUARD_PEERS_REMOVE, bp, rule="/peers/<name>", transform=_peer_name_echo
)
def remove_peer_bp():
"""DELETE /api/wireguard/peers/<name> — Remove a peer from the configuration."""
@daemon_route(GET_WIREGUARD_PEERS, bp)
def peers_bp():
"""GET /api/wireguard/peers — List all configured WireGuard peers."""
@daemon_route(GET_WIREGUARD_PEER_STATUS, bp)
def peer_status_bp():
"""GET /api/wireguard/peer-status — Get real-time status for all peers."""
@daemon_route(
POST_WIREGUARD_GENERATE_CLIENT, bp, body=_gen_client_body, transform=_config_echo
)
def generate_client_bp():
"""POST /api/wireguard/generate-client — Generate a client config for a peer."""
# ---------------------------------------------------------------------------
# Access classes
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CLASSES, bp)
def list_classes_bp():
"""List all access classes.
Endpoint: GET /api/wireguard/classes
"""
try:
return _ok(get(GET_WIREGUARD_CLASSES))
except RuntimeError as exc:
logger.error("Failed to list access classes: %s", exc)
return _error(str(exc), 500)
"""GET /api/wireguard/classes — List all access classes."""
@bp.route("/classes", methods=["POST"])
@daemon_route(POST_WIREGUARD_CLASSES, bp, precheck=_class_key_precheck)
def create_class_bp():
"""Create a new access class.
Endpoint: POST /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = post(POST_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Create access class rejected: %s", exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Create access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to create access class: %s", exc)
return _error(str(exc), 500)
"""POST /api/wireguard/classes — Create a new access class."""
@bp.route("/classes", methods=["PATCH"])
@daemon_route(
PATCH_WIREGUARD_CLASSES, bp, rule="/classes", precheck=_class_key_precheck
)
def update_class_bp():
"""Update an access class.
Endpoint: PATCH /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = patch(PATCH_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Update access class rejected: %s", exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update access class: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/wireguard/classes — Update an access class."""
@bp.route("/classes", methods=["DELETE"])
@daemon_route(
DELETE_WIREGUARD_CLASSES,
bp,
rule="/classes",
precheck=require_dict_body,
body=_delete_class_body,
)
def delete_class_bp():
"""Delete an access class.
Endpoint: DELETE /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
logger.info("Access class '%s' deleted via API", key)
return _ok(result)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("Delete access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to delete access class: %s", exc)
return _error(str(exc), 500)
"""DELETE /api/wireguard/classes — Delete an access class."""
@bp.route("/classes/<key>/up", methods=["POST"])
def class_up_bp(key):
"""Bring up a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/up
"""
try:
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought up via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
POST_WIREGUARD_CLASSES_UP,
bp,
rule="/classes/<key>/up",
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_up_bp():
"""POST /api/wireguard/classes/<key>/up — Bring up a class's tunnel."""
@bp.route("/classes/<key>/down", methods=["POST"])
def class_down_bp(key):
"""Bring down a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/down
"""
try:
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought down via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_WIREGUARD_CLASSES_DOWN,
bp,
rule="/classes/<key>/down",
methods=["POST"],
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_down_bp():
"""POST /api/wireguard/classes/<key>/down — Bring down a class's tunnel."""
@bp.route("/classes/<key>/status", methods=["GET"])
def class_status_bp(key):
"""Get status for a single access class's tunnel.
Endpoint: GET /api/wireguard/classes/<key>/status
"""
try:
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
GET_WIREGUARD_CLASS_STATUS,
bp,
rule="/classes/<key>/status",
params={"class_key": "key"},
)
def class_status_bp():
"""GET /api/wireguard/classes/<key>/status — Get status for a class's tunnel."""
@bp.route("/classes/keys/<key>", methods=["POST"])
def class_init_keys_bp(key):
"""Generate key pair for a single access class.
Endpoint: POST /api/wireguard/classes/keys/<key>
"""
try:
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
logger.info("WireGuard class '%s' keys generated via API", key)
return _ok(None)
except NotFound as exc:
logger.info("Class '%s' not found for keys: %s", key, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate keys for class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
POST_WIREGUARD_CLASS_INIT_KEYS,
bp,
rule="/classes/keys/<key>",
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_init_keys_bp():
"""POST /api/wireguard/classes/keys/<key> — Generate keys for a class."""
+3 -1
View File
@@ -2,7 +2,9 @@
server.py - Vacuum Wall management WebUI entry point.
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
+38 -4
View File
@@ -107,19 +107,53 @@ export const _toasts = [];
const _toastIds = { next: 1 };
/**
* Show a toast notification. Auto-dismisses after `duration` ms.
* Default auto-dismiss durations per toast type (ms). 0 = never
* auto-dismiss. Errors stay on screen until dismissed so long
* failure messages remain readable.
*/
const _TOAST_DEFAULT_DURATIONS = {
info: 4000,
success: 4000,
warning: 8000,
error: 0,
};
/**
* Auto-dismiss timer that pauses while the toast is hovered.
* Re-checks in 1s while hovered instead of dismissing.
*/
function _scheduleToastDismiss(id, delay) {
setTimeout(() => {
const t = _toasts.find(t => t.id === id);
if (!t) return;
if (t.hovered) _scheduleToastDismiss(id, 1000);
else dismissToast(id);
}, delay);
}
/**
* Show a toast notification.
*
* 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. An explicit `duration` overrides
* the default. The auto-dismiss timer pauses while the toast is
* hovered.
*
* @param {string} message Toast text
* @param {string} [type] 'info' | 'success' | 'error' | 'warning'
* @param {number} [duration] Auto-dismiss timeout in ms (0 = indefinite)
* @returns {number} id
*/
export function toast(message, type = 'info', duration = 4000) {
export function toast(message, type = 'info', duration) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
const dur = duration === undefined
? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000)
: duration;
_toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false });
requestUpdate();
if (duration > 0) setTimeout(() => dismissToast(id), duration);
if (dur > 0) _scheduleToastDismiss(id, dur);
return id;
}
+39 -4
View File
@@ -73,14 +73,17 @@ export function createAuthModel() {
const json = await r.json();
if (!json.ok || !json.data?.user) return null;
// 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 {
token: stored.access,
refresh: stored.refresh,
session_id: stored.session_id,
user: json.data.user,
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,
user: payload.user,
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,
user: json.data.user ?? prev?.user,
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.
* @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 { h } from './vdom.js';
import { _compExpandedCache } from './render.js';
/** Registry of mounted components: key → { state } */
const _mounted = new Map();
@@ -26,6 +25,7 @@ const _mounted = new Map();
* Define a page component.
*
* @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.load] Optional one-time setup called on mount
* @param {function} def.render Render function that returns vnodes
@@ -53,6 +53,7 @@ export function definePage(def) {
},
load: def.load || null,
onUnmount: def.onUnmount || null,
title: def.title || null,
};
return renderer;
@@ -66,6 +67,8 @@ export function mountComponent(key, renderer) {
const pd = renderer._pageDef;
if (!pd) return;
if (pd.title) document.title = pd.title;
let entry = _mounted.get(key);
if (entry) {
@@ -90,7 +93,7 @@ export function mountComponent(key, renderer) {
* Unmount a page component. Called by the render engine when a #comp vnode
* is removed from the tree.
*/
export function unmountComponent(key, renderer) {
export function unmountComponent(key, renderer, compCache) {
const entry = _mounted.get(key);
if (!entry) return;
@@ -103,7 +106,7 @@ export function unmountComponent(key, renderer) {
try { pd.onUnmount(entry.state); } catch (_) {}
}
_compExpandedCache.delete(key);
if (compCache) compCache.delete(key);
_mounted.delete(key);
}
+51 -4
View File
@@ -54,17 +54,54 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
return vnodeList;
}
/**
* Decide the toasts for an apply-all response payload.
*
* The endpoint returns 200 with `{ applied, errors }` even when some
* subsystems failed (e.g. the firewall safety guards refused a change), so
* `resp.ok` alone is not a success signal. An error always wins: when any
* subsystem failed, report it and suppress the success toast.
*
* @param {object} data Response payload `{ applied, errors }`
* @param {string} [successMsg] Message for the success toast
* @returns {{error: string|null, success: string|null}}
*/
export function applyResultToasts(data, successMsg) {
const errs = (data && data.errors) || {};
const entries = Object.entries(errs);
if (entries.length) {
return {
error: 'Apply failed for: ' +
entries.map(([k, v]) => `${k}${v}`).join('; '),
success: null,
};
}
const applied = (data && data.applied) || [];
return {
error: null,
success: applied.length ? successMsg : null,
};
}
/**
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*
* @param {string} successMsg Success toast message
* @param {boolean} [force] Forward `{"force": true}` to override the
* firewall safety guards
*/
async function doApply(successMsg) {
async function doApply(successMsg, force) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
const opts = { method: 'POST' };
if (force) opts.body = { force: true };
const resp = await apiFetch('/api/status/apply-all', opts);
if (resp.ok) {
toast(successMsg, 'success');
const t = applyResultToasts(resp.data, successMsg);
if (t.error) toast(t.error, 'error', 8000);
else if (t.success) toast(t.success, 'success');
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
@@ -90,6 +127,12 @@ async function openApplyModal(successMsg) {
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
// Only meaningful when the firewall has pending changes (the only
// subsystem whose apply honours `force`); the checkbox tracks its own
// DOM state — no reactivity needed.
const fwPending = isPending(pendingData.firewall) &&
((pendingData.firewall.changes || []).length > 0);
let force = false;
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
@@ -106,7 +149,11 @@ async function openApplyModal(successMsg) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
<span class="text-sm">Force apply <span class="text-muted"> overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
</label>` : ''}
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
</div>`);
});
}
+34 -10
View File
@@ -37,6 +37,13 @@ export function StatusDot(props = {}) {
return h('span', { class: `status-dot status-${v}` });
}
/**
* Small amber dot marking a pending (edited, not yet applied) element.
*/
export function PendingDot() {
return h('span', { class: 'pending-dot' });
}
/**
* Empty-state placeholder card.
*
@@ -55,16 +62,20 @@ export function Empty(props = {}) {
* @param {object} props
* @param {string} [props.header]
* @param {VNode[]} [props.children]
* @param {string} [props.cls] - Extra class appended to the outer `div.card`
* @param {string} [props.title] - Tooltip on the outer `div.card`
*/
export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
const title = props.title ? { title: props.title } : {};
if (props.header) {
return h('div', { class: 'card', ...key },
return h('div', { class: cls, ...title, ...key },
h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []),
);
}
return h('div', { class: 'card', ...key }, props.children || []);
return h('div', { class: cls, ...title, ...key }, props.children || []);
}
/**
@@ -171,13 +182,22 @@ export function ActionButton(props = {}) {
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
// Batch endpoints (e.g. /api/status/apply-all) return 200
// with an `errors` map when some operations failed —
// `resp.ok` alone is not a success signal.
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
const errEntries = errs ? Object.entries(errs) : [];
if (errEntries.length) {
toast('Failed: ' + errEntries.map(([k, v]) => `${k}${v}`).join('; '), 'error', 8000);
} else {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
}
if (msg) toast(msg, 'success');
}
if (msg) toast(msg, 'success');
if (props.onSuccess) props.onSuccess();
// No modelFetch — WS delta updates state store models.
} else {
@@ -200,6 +220,8 @@ export function ActionButton(props = {}) {
* @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key
* @param {string} [props.cls] - Extra class appended to the wrapper (or div.card)
* @param {string} [props.title] - Tooltip on the wrapper element
*/
export function Table(props = {}) {
const cols = props.columns || [];
@@ -216,10 +238,12 @@ export function Table(props = {}) {
),
);
const key = props.key !== undefined ? { key: props.key } : {};
const title = props.title ? { title: props.title } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table);
return h('div', { class: cls, ...title, ...key }, table);
}
return h('div', key, table);
return h('div', { ...title, ...key }, table);
}
/**
+41 -6
View File
@@ -3,10 +3,38 @@
*
* ToastContainer component that renders queued toast notifications.
* Uses the toast/dismissToast state from api.js.
*
* Long messages (>200 chars or containing newlines) render compact
* first line with an ellipsis plus a "Details" button that opens a
* modal with the full text. Dismissal is only via the × button;
* hovering the toast pauses its auto-dismiss timer.
*/
import { h } from '../vdom.js';
import { _toasts, dismissToast } from '../api.js';
import { openModal } from './modal.js';
/** Messages longer than this (or containing newlines) render compact. */
const _LONG_MESSAGE_CHARS = 200;
function _isLong(message) {
return message.length > _LONG_MESSAGE_CHARS || message.includes('\n');
}
function _firstLine(message) {
return message.split('\n')[0].trim();
}
function _showDetails(t) {
openModal(
h('div', null,
h('h2', { class: 'modal-title' }, 'Details'),
h('div', { class: 'modal-body' },
h('pre', { class: 'toast-details-msg' }, t.message),
),
),
);
}
/**
* Render all pending toast notifications.
@@ -24,19 +52,26 @@ export function ToastContainer() {
};
return h('div', { class: 'toast' },
..._toasts.map(t =>
h('div', {
..._toasts.map(t => {
const long = _isLong(t.message);
return h('div', {
class: `toast-message ${clsMap[t.type] || clsMap.info}`,
'on:click': () => dismissToast(t.id),
'on:mouseover': () => { t.hovered = true; },
'on:mouseout': () => { t.hovered = false; },
},
h('span', { class: 'toast-text' }, t.message),
h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') },
long ? _firstLine(t.message) : t.message),
h('div', { class: 'toast-actions' },
long ? h('button', {
class: 'toast-btn toast-details',
'on:click': (e) => { e.stopPropagation(); _showDetails(t); },
}, 'Details') : null,
h('button', {
class: 'toast-btn toast-close',
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
}, '\u00d7'),
),
),
),
);
}),
);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Hoover dirty.js
*
* Marks UI elements that have been edited (saved to config) but not yet
* applied to the live system. Consumes the daemon-provided pending state:
* - hash subsystems: status.pending_diff -> [{path, action, old, new}]
* - firewall: pending -> {needs_apply, pending:[{zone,type,...}]}
*
* "Line" matching: an element path is dirty when it shares a root-to-leaf line
* with a pending path equal, an ancestor, or a descendant. A plain key is a
* prefix of its indexed form, so a whole-list change (e.g. `dhcp.ranges`)
* marks every row, while a leaf change (`interface.listen_port`) marks only
* that field/row.
*/
function segs(p) {
return p ? String(p).split('.').filter(Boolean) : [];
}
// Is segment `a` a prefix of segment `b`? "ranges" prefixes "ranges[0]"
// (the trailing bracket keeps "ranges[1" from prefixing "ranges[12]").
function segPrefix(a, b) {
return a === b || b.indexOf(a + '[') === 0;
}
// Are paths p and q on the same root-to-leaf line? Segment matching is
// bidirectional so both directions of containment hold: a pending leaf under
// the element (`dhcp.ranges` vs `dhcp.ranges[0].start`) and a pending
// container over the element (`dhcp.ranges[3]` vs `dhcp.ranges`).
function isLine(p, q) {
const A = segs(p);
const B = segs(q);
if (!A.length || !B.length) return false;
const n = Math.min(A.length, B.length);
for (let i = 0; i < n; i++) {
if (!segPrefix(A[i], B[i]) && !segPrefix(B[i], A[i])) return false;
}
return true;
}
/**
* Sentinel path marking "pending but no diff baseline": the config was
* saved but never applied, so the daemon has no applied snapshot to diff
* against and `pending_diff` is empty while `pending_changes` is true.
* Every element is dirty in this case.
*/
const ANY_PATH = Symbol('dirty: any');
/** Set of pending config paths from a hash-subsystem status object. */
export function dirtySet(status) {
const diff = status && Array.isArray(status.pending_diff) ? status.pending_diff : [];
const s = new Set();
for (const d of diff) {
if (d && d.path) s.add(String(d.path));
}
if (!s.size && status && status.pending_changes) s.add(ANY_PATH);
return s;
}
/** True when element path `path` is (under / above / equal to) a pending change. */
export function isDirty(set, path) {
if (!set || !set.size) return false;
if (set.has(ANY_PATH)) return true;
const p = String(path || '');
for (const q of set) {
if (isLine(p, q)) return true;
}
return false;
}
/** Tooltip listing the concrete pending field(s) that affect `path`. */
export function dirtyTitle(set, path) {
if (!set || !set.size) return '';
if (set.has(ANY_PATH)) return 'Configuration saved but not applied yet';
const p = String(path || '');
const hits = [...set].filter((q) => isLine(p, q)).sort();
if (!hits.length) return '';
return 'Unapplied changes: ' + hits.join(', ');
}
/** One object for a hash-subsystem element. Use class/title on the element. */
export function dirtyInfo(set, path) {
const dirty = isDirty(set, path);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? dirtyTitle(set, path) : '',
};
}
const CLEAN_INFO = { dirty: false, class: '', title: '' };
/**
* One object for a container element, covering pending changes under `root`
* that no longer have a live child element to mark: a removed dict key
* (e.g. `peers.p1`) leaves its pending path with no row/section for a
* per-element marker to attach to. `children` is the list of element paths
* for the container's live children (e.g. `'peers.' + name` per configured
* peer). Clean when there are no such orphaned paths, when the set is the
* never-applied sentinel (every element is already marked), or when the root
* itself is pending (every child row is marked instead).
*/
export function orphanInfo(set, root, children) {
if (!set || !set.size || set.has(ANY_PATH)) return CLEAN_INFO;
const r = String(root || '');
const childList = (children || []).map(String);
const hits = [];
for (const q of set) {
if (q === r || !isLine(r, q)) continue;
if (!childList.some((cp) => isLine(q, cp))) hits.push(q);
}
if (!hits.length) return CLEAN_INFO;
return {
dirty: true,
class: 'config-dirty',
title: 'Unapplied changes: ' + hits.sort().join(', '),
};
}
// ── Firewall (zone + type granularity) ─────────────────────────
/** Map<zone, Set<type>> from a firewall pending object. */
export function fwDirty(pending) {
const m = new Map();
const list = pending && Array.isArray(pending.pending) ? pending.pending : [];
for (const c of list) {
if (!c || !c.zone) continue;
if (!m.has(c.zone)) m.set(c.zone, new Set());
if (c.type) m.get(c.zone).add(c.type);
}
return m;
}
/** True when `zone` (and optionally `type`) has a pending firewall change. */
export function fwIsDirty(map, zone, type) {
if (!map || !map.size) return false;
const types = map.get(zone);
if (!types) return false;
if (type) return types.has(type);
return true;
}
/** Tooltip for a firewall zone (and optional type). */
export function fwTitle(map, zone, type) {
const types = map.get(zone);
if (!types) return '';
const t = [...types].sort();
const shown = type ? t.filter((x) => x === type) : t;
if (!shown.length) return '';
return 'Unapplied changes: ' + shown.join(', ');
}
/** One object for a firewall element (zone, optional type). */
export function fwInfo(map, zone, type) {
const dirty = fwIsDirty(map, zone, type);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? fwTitle(map, zone, type) : '',
};
}
+4 -1
View File
@@ -47,7 +47,7 @@ export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob }
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
/* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js';
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect, PendingDot } from './components/data.js';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
@@ -60,3 +60,6 @@ export { ToastContainer } from './components/toast.js';
/* ── UI Components: QR Code ──────────────────────────────────── */
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js';
/* ── Dirty / pending-edit markers ─────────────────────────────── */
export { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from './dirty.js';
+46 -22
View File
@@ -18,11 +18,31 @@ export const _renderSlots = new Map();
/** Container → render function */
export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
export const _compExpandedCache = new Map();
/** Container → (component key → last normalized #comp output, for _vnodeDom preservation) */
const _compExpandedCaches = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */
const _compRegistry = new Map();
/** Container (component key renderer function). Per-container: a commit of one
* 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.
@@ -69,8 +89,9 @@ function commit(container) {
if (typeof result === 'function') result = result();
const prev = _renderSlots.get(container);
// Normalize: expand #comp vnodes and track lifecycle
const vnodes = normalizeVNodesWithLifecycle(result, prev);
// Normalize: expand #comp vnodes and track lifecycle (this container's own
// registry — other roots' commits must not touch our component keys).
const vnodes = normalizeVNodesWithLifecycle(result, prev, container);
if (!prev) {
for (const v of vnodes) {
@@ -89,16 +110,18 @@ function commit(container) {
* Normalize render output: filter nulls, expand #comp vnodes,
* and manage component lifecycle based on key changes.
*/
function normalizeVNodesWithLifecycle(result, prevVnodes) {
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
function normalizeVNodesWithLifecycle(result, prevVnodes, container) {
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 newEntries = [];
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
const normalized = normalizeRecursive(result, oldKeyMap, newEntries, null, compCache);
for (const entry of oldEntries) {
if (!newEntries.some(e => e.key === entry.key)) {
unmountComponent(entry.key, entry.renderer);
unmountComponent(entry.key, entry.renderer, compCache);
}
}
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,
// so collectCompEntries always returns [] after the first render)
// Sync this container's registry with the current render (prevVnodes are
// normalized and lack #comp tags, so collectCompEntries always returns []
// after the first render)
const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) {
if (!newKeySet.has(key)) _compRegistry.delete(key);
for (const [key] of registry) {
if (!newKeySet.has(key)) registry.delete(key);
}
for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer);
registry.set(entry.key, entry.renderer);
}
return normalized;
@@ -127,13 +151,13 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
* When prevCh is provided, preserves _vnodeDom entries so that diff
* 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 (Array.isArray(result)) {
const flat = [];
let idx = 0;
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++;
}
return flat;
@@ -152,19 +176,19 @@ function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
}
if (renderer && typeof renderer === 'function') {
const content = renderer();
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded);
if (key !== undefined) _compExpandedCache.set(key, result);
const prevExpanded = key !== undefined && compCache ? compCache.get(key) : null;
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded, compCache);
if (key !== undefined && compCache) compCache.set(key, result);
return result;
}
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh, compCache);
}
const rawChildren = vnode.ch || [];
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
const children = [];
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);
}
+12 -7
View File
@@ -1,4 +1,4 @@
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js';
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
import { _deleting } from '/static/hoover/components/data.js';
@@ -256,22 +256,27 @@ export function openBackendModal(state, backend) {
// Page
// ---------------------------------------------------------------------------
export default definePage({
title: 'Backends - Vacuum Wall',
init() {
return {
backends: getModel('backends'),
dnsmasq: getModel('dnsmasq'),
nginx: getModel('nginx'),
};
},
render(state) {
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const backends = state.backends.data || {};
const entries = Object.entries(backends);
const rows = entries.map(([name, b]) =>
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
<td><strong>${esc(name)}</strong></td>
const rows = entries.map(([name, b]) => {
const info = dirtyInfo(set, 'backends.' + name);
const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td>
<td>
@@ -291,11 +296,11 @@ export default definePage({
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
onComplete=${() => modelFetch('backends')}
label="Delete" />`
label="Delete" />`}
}
</td>
</tr>`
);
</tr>`;
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
+11
View File
@@ -326,6 +326,7 @@ async function pollCertIssue(rid) {
}
export default definePage({
title: 'Certificates - Vacuum Wall',
init() {
return {
acme: getModel('acme'),
@@ -336,6 +337,7 @@ export default definePage({
if (guard) return guard;
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
const certError = state.acme.data?.status?.error;
const rows = (state.acme.data?.certs || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
@@ -363,6 +365,15 @@ export default definePage({
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
}),
_accountCard(account),
certError
? html`<div class="card">
<div class="card-body">
<div class="text-warning text-sm">
Certificate data unavailable: ${esc(certError)}
</div>
</div>
</div>`
: null,
rows.length
? Table({
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
+3 -2
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ApplyConfirm, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
// Render a single firewall change as "current → new".
// `live` is the currently applied value; `config` is the target value it
@@ -41,6 +41,7 @@ function diffLine(d) {
}
export default definePage({
title: 'Dashboard - Vacuum Wall',
init() {
return {
firewall: getModel('firewall'),
@@ -158,7 +159,7 @@ export default definePage({
</li>`)}
</ul>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
<${ApplyConfirm} pending=${true} label="Apply All Changes"
successMsg="All changes applied"
cls="btn btn-sm btn-primary" />
<${CancelConfirm} cls="btn btn-sm btn-danger" />
+23 -11
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
function makeAddRange(activeZones, interfaces) {
const opts = [
@@ -107,6 +107,7 @@ const addDns = QuickModal({
});
export default definePage({
title: 'DHCP & DNS - Vacuum Wall',
init() {
return {
dnsmasq: getModel('dnsmasq'),
@@ -118,6 +119,7 @@ export default definePage({
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
if (guard) return guard;
const set = dirtySet(state.dnsmasq.data?.status);
const cfg = state.dnsmasq.data?.config || {};
const dhcpCfg = cfg.dhcp || {};
const dnsCfg = cfg.dns || {};
@@ -126,8 +128,10 @@ export default definePage({
const dnsRecords = dnsCfg.custom_records || [];
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
<td>${r.interface || '(global)'}</td>
const rangesRows = ranges.map((r, i) => {
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'}</td>
<td>${esc(r.start)}</td>
<td>${esc(r.end)}</td>
<td>${esc(r.lease_time || '12h')}</td>
@@ -139,10 +143,13 @@ export default definePage({
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed" />
</td>
</tr>`);
</tr>`;
});
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
<td>${esc(l.mac)}</td>
const leaseRows = staticLeases.map((l, i) => {
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${esc(l.mac)}</td>
<td>${esc(l.ip)}</td>
<td>${l.hostname || '-'}</td>
<td>
@@ -152,10 +159,13 @@ export default definePage({
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed" />
</td>
</tr>`);
</tr>`;
});
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
const dnsRows = dnsRecords.map((rec, i) => {
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(rec.name || 'unnamed')}</strong></td>
<td class="text-sm">${esc(rec.address || '-')}</td>
<td>
<${ConfirmDelete}
@@ -164,7 +174,8 @@ export default definePage({
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed" />
</td>
</tr>`);
</tr>`;
});
const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', {
@@ -180,7 +191,8 @@ export default definePage({
};
const currentDomain = dnsCfg.domain || null;
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
const domainInfo = dirtyInfo(set, 'dns.domain');
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} style="margin-bottom: 1rem;">
<label style="font-weight: 600;">Search Domain</label>
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
+9 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({
});
export default definePage({
title: 'Interfaces - Vacuum Wall',
init() {
return {
firewall: getModel('firewall'),
@@ -45,6 +46,7 @@ export default definePage({
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
const set = dirtySet(state.network.data?.status);
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = Object.keys(fwZones);
@@ -72,9 +74,10 @@ export default definePage({
};
});
const rows = ifaces.map(iface =>
html`<tr key=${iface.name}>
<td><strong>${iface.name}</strong></td>
const rows = ifaces.map(iface => {
const info = dirtyInfo(set, 'interfaces.' + iface.name);
return html`<tr key=${iface.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${iface.name}</strong></td>
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
<td><${StatusText} status=${iface.state} /></td>
@@ -84,8 +87,8 @@ export default definePage({
<button class="btn btn-sm btn-outline" style="margin-left:8px"
onClick=${() => cfgModalFn(iface)}>Config</button>
</td>
</tr>`
);
</tr>`;
});
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
+2 -1
View File
@@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => {
};
const Page = definePage({
title: 'Login - Vacuum Wall',
init() {
document.title = 'Login — Vacuum Wall';
return {};
},
load() {
+1
View File
@@ -9,6 +9,7 @@ const logTabs = [
];
export default definePage({
title: 'Logs - Vacuum Wall',
init() {
return {
logs: getModel('logs'),
+9 -5
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -24,6 +24,7 @@ const addFwd = QuickModal({
});
export default definePage({
title: 'NAT - Vacuum Wall',
init() {
return {
firewall: getModel('firewall'),
@@ -33,6 +34,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
@@ -82,8 +84,9 @@ export default definePage({
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
</tr>`;
}
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
const info = fwInfo(fw, zone, 'masquerade');
return html`<tr key=${'m-' + zone} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
@@ -102,8 +105,9 @@ export default definePage({
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
<td><strong>${zone}</strong></td>
const info = fwInfo(fw, zone, 'forward_ports');
fwRows.push(html`<tr key=${'f-' + zone + '-' + i} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
+1
View File
@@ -1,6 +1,7 @@
import { html, PageHeader, definePage } from '/static/hoover/index.js';
export default definePage({
title: '404 - Vacuum Wall',
init() {
return { path: location.hash.slice(1) || '' };
},
+1 -1
View File
@@ -257,8 +257,8 @@ function CredentialsPage() {
}
const Page = definePage({
title: 'Passkeys - Vacuum Wall',
init() {
document.title = 'Passkeys — Vacuum Wall';
return state;
},
+12 -8
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) {
@@ -172,8 +172,9 @@ function editDomain(d, state) {
modal({});
}
function domainRow(domainName, domainPaths, state) {
function domainRow(domainName, domainPaths, state, set) {
const d = domainPaths[0];
const info = dirtyInfo(set, 'domains.' + domainName);
const certMap = certLookup(state.acme ? state.acme.data : null);
const cert = certMap[d.domain];
let certBadge, certTitle;
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
return html`<tr key=${domainName} class="domain-row ${info.class}" title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(domainName)}</strong></td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
</tr>`;
}
function backendSection(section, state) {
function backendSection(section, state, set) {
const { backendName, backend, domains } = section;
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
const info = dirtyInfo(set, 'backends.' + backendName);
const rows = domains.map(d => domainRow(d.domain, d.paths, state, set));
const sectionActions = [];
if (!backend.builtin) {
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
@@ -233,7 +235,7 @@ function backendSection(section, state) {
}
}
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
return html`<div class="backend-section ${info.class}" title=${info.title || undefined} key=${backendName} style="margin-bottom:24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
<${Badge} text=${esc(backendName)} variant="primary" />
@@ -248,6 +250,7 @@ function backendSection(section, state) {
}
export default definePage({
title: 'Proxy - Vacuum Wall',
init() {
return {
nginx: getModel('nginx'),
@@ -258,10 +261,11 @@ export default definePage({
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends);
const sectionVNodes = sections.map(s => backendSection(s, state));
const sectionVNodes = sections.map(s => backendSection(s, state, set));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
+9 -2
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -15,6 +15,7 @@ const addRule = QuickModal({
});
export default definePage({
title: 'Rules - Vacuum Wall',
init() {
return {
firewall: getModel('firewall'),
@@ -24,6 +25,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {};
@@ -34,6 +36,7 @@ export default definePage({
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
const info = fwInfo(fw, zone, 'rich_rules');
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
@@ -50,8 +53,12 @@ export default definePage({
</tr>`;
});
return Card({
header: 'Zone: ' + esc(zone),
header: info.dirty
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
: 'Zone: ' + esc(zone),
key: zone,
cls: info.class || undefined,
title: info.title || undefined,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: ruleRows,
+1
View File
@@ -252,6 +252,7 @@ function UsersPage() {
}
export default definePage({
title: 'Users - Vacuum Wall',
init() {
return state;
},
+20 -7
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
/* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() {
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
}
const classStatuses = status?.classes || {};
const set = dirtySet(status);
const rows = entries.map(([k, v]) => {
const pCount = peerCountMap[k] || 0;
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
const isUp = clsStatus.up;
const hasKeys = classHasKeys(v);
const color = classColor(k);
return html`<tr key=${k}>
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<tr key=${k} class=${info.class || undefined} title=${info.title || undefined}>
<td style="border-left: 3px solid ${color}">${info.dirty ? PendingDot({}) : ''}<strong>${esc(k)}</strong></td>
<td>${esc(v.name || k)}</td>
<td class="text-sm">${esc(v.description || '-')}</td>
<td class="text-sm">${esc(v.subnet || '-')}</td>
@@ -400,6 +402,7 @@ function renderAccessClasses(config, status) {
/* ── Main Page ───────────────────────────────────────────────── */
export default definePage({
title: 'WireGuard - Vacuum Wall',
init() {
return {
wireguard: getModel('wireguard'),
@@ -418,12 +421,16 @@ export default definePage({
const wgData = state.wireguard.data;
const st = wgData?.status || {};
const config = wgData?.config || {};
const set = dirtySet(st);
const isUp = st.up || false;
const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || '';
// Build merged peer rows: configured peers + live status
const configuredPeers = wgData?.peers || [];
// A removed peer leaves a `peers.<name>` pending path with no live row
// to attach a per-row marker to; surface it on the table itself.
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
const statusPeersMap = {};
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
for (const sp of (cSt.peers || [])) {
@@ -442,9 +449,11 @@ export default definePage({
const isConnected = sp && !!sp.latest_handshake;
const accessClass = p.access_class;
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
return html`<tr key=${p.name}${borderColor}>
const info = dirtyInfo(set, 'peers.' + p.name);
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
return html`<tr key=${p.name} class=${info.class || undefined} title=${info.title || undefined} style=${style}>
<td>
${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong>
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
@@ -480,7 +489,8 @@ export default definePage({
const isUp = cSt.up;
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
const color = classColor(k);
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<div key=${k} class="card ${info.class}" title=${info.title || undefined} style="border-left: 3px solid ${color}">
<div class="card-header d-flex justify-content-between align-items-center">
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
@@ -502,9 +512,10 @@ export default definePage({
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
}
const ifaceInfo = dirtyInfo(set, 'interface');
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title=${'Interface Settings' + (ifaceInfo.dirty ? ' — ' + ifaceInfo.title : '')}>${ifaceInfo.dirty ? PendingDot({}) : ''}\u{1F527}</button>`,
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
@@ -531,6 +542,8 @@ export default definePage({
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
cls: peersOrphan.class || undefined,
title: peersOrphan.title || undefined,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st),
+13 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
// Services shown by default in the service picker. Everything else is only
// visible with the "Show all options" toggle (or while it is already
@@ -24,6 +24,7 @@ const addZone = QuickModal({
});
export default definePage({
title: 'Zones - Vacuum Wall',
init() {
return {
firewall: getModel('firewall'),
@@ -33,6 +34,8 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
// Live zone data (parsed `--list-all-zones`): carries interfaces,
// services, target, and masquerade for every defined zone.
const liveZones = state.firewall.data?.zones || {};
@@ -45,22 +48,26 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return html`<div class="card" key=${name} style="position:relative">
const info = fwInfo(fw, name);
const ifTitle = fwTitle(fw, name, 'interfaces');
const svcTitle = fwTitle(fw, name, 'services');
const tgtTitle = fwTitle(fw, name, 'target');
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px">
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${ifTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`}
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${svcTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
+62
View File
@@ -380,6 +380,37 @@ body {
white-space: pre-wrap;
}
/* Compact rendering for long messages: single line, ellipsized. */
.toast-message .toast-text-long {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-message .toast-details {
font-size: 12px;
text-decoration: underline;
text-underline-offset: 2px;
padding: 0 4px;
}
/* Full message inside the toast Details modal. */
.toast-details-msg {
font-family: monospace;
font-size: 0.8rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 60vh;
overflow-y: auto;
color: var(--text);
}
.toast-message .toast-actions {
display: flex;
gap: 2px;
@@ -1062,6 +1093,37 @@ body {
color: var(--text-muted);
}
/* Pending (edited, not yet applied) marker amber, distinct from the red
.pending-delete. Applied to rows/cards/sections whose config is dirty. */
.config-dirty {
background: rgba(243, 156, 18, 0.07);
}
tr.config-dirty > td:first-child {
box-shadow: inset 3px 0 0 var(--warning);
}
/* A row queued for deletion (red) takes precedence over the dirty marker. */
tr.pending-delete.config-dirty > td:first-child {
box-shadow: none;
}
.card.config-dirty,
.backend-section.config-dirty,
.domain-config.config-dirty {
box-shadow: inset 3px 0 0 var(--warning);
}
.pending-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning);
margin-right: 6px;
vertical-align: middle;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {