From faa076370dd113637da9f780df6ed16722d87c51 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 3 Sep 2026 00:40:56 +0000 Subject: [PATCH] refactor: daemon collectors, thin webui proxies, pure config reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 27 +- HARDEN.md | 289 ----- daemon/collectors/__init__.py | 26 + daemon/collectors/acme.py | 165 +++ daemon/collectors/dnsmasq.py | 85 ++ daemon/collectors/firewall.py | 175 +++ daemon/collectors/networkd.py | 67 ++ daemon/collectors/nginx.py | 66 ++ daemon/collectors/system.py | 125 ++ daemon/collectors/wireguard.py | 125 ++ daemon/handlers/acme.py | 10 +- daemon/handlers/common.py | 26 + daemon/handlers/dnsmasq.py | 91 +- daemon/handlers/firewall.py | 239 ++-- daemon/handlers/network.py | 25 +- daemon/handlers/wireguard.py | 76 +- daemon/server.py | 35 +- docs/api.md | 36 +- docs/config.md | 21 +- docs/state-model.md | 23 +- lib/acme.py | 7 +- lib/bootstrap.py | 40 + lib/common.py | 19 + lib/dnsmasq.py | 13 +- lib/firewall.py | 92 +- lib/network.py | 12 +- lib/nginx.py | 33 +- lib/state.py | 1009 +---------------- lib/wireguard.py | 13 +- tests/test_acme.py | 14 + tests/test_bootstrap.py | 62 + tests/test_common.py | 51 + tests/test_firewall.py | 318 ++++-- tests/test_network.py | 5 +- tests/test_network_integration.py | 22 +- tests/test_nginx.py | 20 +- tests/test_schema_types.py | 40 +- tests/test_state.py | 50 +- tests/test_wireguard.py | 40 +- webui/api/certs.py | 314 ++--- webui/api/common.py | 191 +++- webui/api/dhcp.py | 407 +++---- webui/api/firewall.py | 773 ++++--------- webui/api/logs.py | 43 +- webui/api/network.py | 175 +-- webui/api/proxy.py | 408 ++----- webui/api/status.py | 99 +- webui/api/wireguard.py | 651 ++++------- .../static/hoover/components/applyconfirm.js | 2 +- 49 files changed, 2834 insertions(+), 3821 deletions(-) delete mode 100644 HARDEN.md create mode 100644 daemon/collectors/__init__.py create mode 100644 daemon/collectors/acme.py create mode 100644 daemon/collectors/dnsmasq.py create mode 100644 daemon/collectors/firewall.py create mode 100644 daemon/collectors/networkd.py create mode 100644 daemon/collectors/nginx.py create mode 100644 daemon/collectors/system.py create mode 100644 daemon/collectors/wireguard.py create mode 100644 daemon/handlers/common.py create mode 100644 lib/bootstrap.py create mode 100644 tests/test_bootstrap.py diff --git a/AGENTS.md b/AGENTS.md index dc18870..77899c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). @@ -118,6 +123,21 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the Pattern for mutations: write JSON → render native config → `sudo ` to apply. Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code. +**Config reads are pure.** Every `lib/.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": }` — `_ok(data)` (Flask) or `ok(data)` (aiohttp) @@ -140,7 +160,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/` (27 test files). All subprocess calls are mocked — no system services required. ```bash .venv/bin/ruff check lib/ webui/ tests/ # lint @@ -160,4 +180,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 | \ No newline at end of file diff --git a/HARDEN.md b/HARDEN.md deleted file mode 100644 index cf947ae..0000000 --- a/HARDEN.md +++ /dev/null @@ -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=` 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 '' 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: }` → `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). diff --git a/daemon/collectors/__init__.py b/daemon/collectors/__init__.py new file mode 100644 index 0000000..c60b6c2 --- /dev/null +++ b/daemon/collectors/__init__.py @@ -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", +] diff --git a/daemon/collectors/acme.py b/daemon/collectors/acme.py new file mode 100644 index 0000000..726b8f9 --- /dev/null +++ b/daemon/collectors/acme.py @@ -0,0 +1,165 @@ +"""ACME state collector.""" + +import logging +import os +from pathlib import Path +from typing import Any + +from lib import schema +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. Legacy .account.conf (acme.sh v2.x) + account_path = acme_home / ".account.conf" + if account_path.is_file(): + 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//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 /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 _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: + from lib.acme import list_certs + + certs = list_certs() + except Exception as exc: + logger.warning("ACME state collection failed", exc_info=True) + certs = [] + cert_error = str(exc) + + account = _parse_account_conf() + + return { + "certs": certs, + "email": email, + "account": account, + "status": {"error": cert_error}, + "timestamp": _now_iso(), + } + + +register_collector("acme", _collect_acme) diff --git a/daemon/collectors/dnsmasq.py b/daemon/collectors/dnsmasq.py new file mode 100644 index 0000000..95d2723 --- /dev/null +++ b/daemon/collectors/dnsmasq.py @@ -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 diff --git a/daemon/collectors/firewall.py b/daemon/collectors/firewall.py new file mode 100644 index 0000000..9276cdc --- /dev/null +++ b/daemon/collectors/firewall.py @@ -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", + } + ), +) diff --git a/daemon/collectors/networkd.py b/daemon/collectors/networkd.py new file mode 100644 index 0000000..abbc8a9 --- /dev/null +++ b/daemon/collectors/networkd.py @@ -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", + } + ), +) diff --git a/daemon/collectors/nginx.py b/daemon/collectors/nginx.py new file mode 100644 index 0000000..4beffdb --- /dev/null +++ b/daemon/collectors/nginx.py @@ -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) diff --git a/daemon/collectors/system.py b/daemon/collectors/system.py new file mode 100644 index 0000000..e6f06fc --- /dev/null +++ b/daemon/collectors/system.py @@ -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//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", + } + ), +) diff --git a/daemon/collectors/wireguard.py b/daemon/collectors/wireguard.py new file mode 100644 index 0000000..f2ca98e --- /dev/null +++ b/daemon/collectors/wireguard.py @@ -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", + } + ), +) diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index b813741..d6abfae 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -592,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"): @@ -607,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"): @@ -622,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) diff --git a/daemon/handlers/common.py b/daemon/handlers/common.py new file mode 100644 index 0000000..28d44f1 --- /dev/null +++ b/daemon/handlers/common.py @@ -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 diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index accdade..0b652e0 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -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"]} diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index e553687..24858f8 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -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} @@ -862,12 +866,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, _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} @@ -938,10 +937,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A 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} @@ -987,12 +983,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: 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} @@ -1044,12 +1035,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An ] 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} @@ -1130,12 +1116,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] zone_cfg["masquerade"] = bool(enable) stamp_applied(cfg) _save_config(cfg) - sync_result = bus.emit( - SyncEvent( - "firewall", "config_saved", {"action": "masquerade_set", "zone": zone} - ) - ) - refresh_state(["firewall", *sync_result.affected_subsystems]) + emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone}) return {"zone": zone, "masquerade": bool(enable)} @@ -1192,12 +1173,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An 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} @@ -1258,12 +1234,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, ] 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} diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index a8a3808..1fb99ff 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -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} diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index a16d078..2f2a941 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -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} diff --git a/daemon/server.py b/daemon/server.py index 5eb86b4..c4227b2 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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) @@ -747,6 +741,13 @@ 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 diff --git a/docs/api.md b/docs/api.md index 80f86bc..48a98fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -433,7 +433,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 +454,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:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards. + +**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 | @@ -478,6 +484,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 | @@ -1983,8 +1993,8 @@ Apply pending changes for all subsystems in dependency order. ``` `force` is forwarded to the firewall apply only — it overrides the -management-lockout and interface-coverage guards. Other subsystems -ignore it. +management-lockout guard and the interface-coverage invariant. Other +subsystems ignore it. **Response (`data`):** @@ -1996,10 +2006,14 @@ ignore it. 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 refuses if an interface would be left without zone coverage (the -coverage guard) or both https/ssh would be stripped from the default zone -(lockout guard); the `ConflictError` surfaces in `errors` under -`"Firewall"` while the other subsystems proceed. +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. --- @@ -2016,6 +2030,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`):** diff --git a/docs/config.md b/docs/config.md index babf6ef..9645f1c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -507,17 +507,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`. | @@ -532,13 +540,18 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr ### 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. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly. Both `/api/firewall/zones//services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift. **Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones//services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed. -**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. diff --git a/docs/state-model.md b/docs/state-model.md index 83cb060..4511d3d 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -24,10 +24,14 @@ return annotation references them. 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. + 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 both decide from this last-poll state (an edit saved within + the last poll interval may not yet be flagged), and cancel reverts only + the declarative config file — live drift (e.g. manual `firewall-cmd`) + survives a cancel. ## State shape summary @@ -62,10 +66,13 @@ Top-level `FirewallState`: // 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 + 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", diff --git a/lib/acme.py b/lib/acme.py index 0372531..752d459 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -95,11 +95,14 @@ def _run_acme(args: list[str]) -> str: acme_home_env, "--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. + # of the raw CA exchange. Last on purpose: acme.sh treats the next + # token after --log as its optional file argument, so a trailing + # --log defaults the log to $LE_CONFIG_HOME/acme.sh.log and can + # never swallow a real argument. "--log", - *args, ] try: diff --git a/lib/bootstrap.py b/lib/bootstrap.py new file mode 100644 index 0000000..050e086 --- /dev/null +++ b/lib/bootstrap.py @@ -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() diff --git a/lib/common.py b/lib/common.py index 828eb0b..bb5dfc7 100644 --- a/lib/common.py +++ b/lib/common.py @@ -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", diff --git a/lib/dnsmasq.py b/lib/dnsmasq.py index 1712b95..2a36779 100644 --- a/lib/dnsmasq.py +++ b/lib/dnsmasq.py @@ -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", diff --git a/lib/firewall.py b/lib/firewall.py index d2b3cc9..7cbd45f 100644 --- a/lib/firewall.py +++ b/lib/firewall.py @@ -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", ] diff --git a/lib/network.py b/lib/network.py index d1502c8..0353708 100644 --- a/lib/network.py +++ b/lib/network.py @@ -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: diff --git a/lib/nginx.py b/lib/nginx.py index ad9e923..db10c93 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -169,31 +169,45 @@ 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) - save_config(raw) - return raw if "ssl" not in raw: 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) - # Read-only unless normalization/migration actually changed the config; - # re-saving on every read rewrites the file (owner/mtime churn). if raw != pre: save_config(raw) - return raw + return True + return False def save_config(cfg: dict[str, Any]) -> None: @@ -616,6 +630,7 @@ __all__ = [ "get_config", "get_domains", "get_management_domains", + "migrate_config_file", "remove_domain", "save_config", "test_config", diff --git a/lib/state.py b/lib/state.py index 860ffb0..c01282c 100644 --- a/lib/state.py +++ b/lib/state.py @@ -2,55 +2,31 @@ Collects system state at startup and on demand. Handlers read from the state instead of invoking subprocesses on every request. + +The collectors themselves live in ``daemon/collectors/`` (they make +read-only ``sudo`` queries and so do not belong in ``lib/``). Importing +that package registers them here as a side effect; registration must +happen before the first ``populate()``/``poll()`` call. """ -import contextlib import logging -import os from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from typing import Any, ClassVar -from lib import schema -from lib.common import ( - _APPLY_HASH_KEY, - _LAST_APPLIED_CONFIG_KEY, - config_hash, - deep_diff, - load_json, - run, - run_proc, - 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.network import parse_networkctl_status - logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent -_CA_NAME_MAP: dict[str, str] = { - "letsencrypt": "Let's Encrypt", - "zerossl": "ZeroSSL", -} - _DEFAULT_POLL_INTERVALS: dict[str, int] = { "firewall": 30, "wireguard": 10, "dnsmasq": 10, "networkd": 10, "system": 1, - # nginx/acme state derives from config files (and lazy in-place migration - # can rewrite them without a mutation); poll so drift self-heals. + # nginx/acme state derives from config files and rendered artifacts on + # disk; poll so drift (manual edits, out-of-band applies) is re-collected. "nginx": 60, "acme": 300, } @@ -420,982 +396,21 @@ def _diff_layers( return (structural, volatile_changed) -# --------------------------------------------------------------------------- -# Firewall collector -# --------------------------------------------------------------------------- - - def _now_iso() -> str: """Return the current UTC time as an ISO 8601 string.""" return datetime.now(UTC).isoformat() -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", - } - ), -) - - -# --------------------------------------------------------------------------- -# DNSMasq collector -# --------------------------------------------------------------------------- - - -def _collect_dnsmasq() -> schema.DnsmasqState: - """Collect dnsmasq status, config, and leases. - - Returns: - Dict containing config, service status, leases, and timestamp. - """ - CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" - CONFIG_PATH = CONFIG_DIR / "config.json" - DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" - LEASE_FILE = "/var/lib/misc/dnsmasq.leases" - - DEFAULT_CFG: dict[str, Any] = { - "dhcp": {"ranges": [], "static_leases": []}, - "dns": { - "upstreams": ["8.8.8.8", "1.1.1.1"], - "domain": None, - "custom_records": [], - }, - } - - # Load config - cfg: dict[str, Any] = {} - if CONFIG_PATH.exists(): - try: - raw = load_json(CONFIG_PATH) - if raw: - from lib.common import deep_merge - - cfg = deep_merge(deepcopy(DEFAULT_CFG), raw) - else: - cfg = deepcopy(DEFAULT_CFG) - except Exception: - cfg = deepcopy(DEFAULT_CFG) - else: - 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 = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( - cfg - ) - - safe_cfg = strip_apply_meta(cfg) - pending_diff: list[dict[str, Any]] = [] - if pending_changes: - snap = cfg.get(_LAST_APPLIED_CONFIG_KEY) - if isinstance(snap, dict): - pending_diff = deep_diff(snap, safe_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 - - -# --------------------------------------------------------------------------- -# Nginx collector -# --------------------------------------------------------------------------- - - -def _collect_nginx() -> schema.NginxState: - """Collect nginx config and domains list. - - Returns: - Dict containing config, domains, and timestamp. - """ - CONFIG_DIR = PROJECT_DIR / "config" / "nginx" - CONFIG_FILE = CONFIG_DIR / "config.json" - SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled" - - DEFAULT_SSL: dict[str, Any] = { - "protocols": "TLSv1.2 TLSv1.3", - "ciphers": ( - "ECDHE-ECDSA-AES128-GCM-SHA256:" - "ECDHE-RSA-AES128-GCM-SHA256:" - "ECDHE-ECDSA-AES256-GCM-SHA384:" - "ECDHE-RSA-AES256-GCM-SHA384:" - "ECDHE-ECDSA-CHACHA20-POLY1305:" - "ECDHE-RSA-CHACHA20-POLY1305" - ), - "prefer_server_ciphers": False, - } - - default_cfg: dict[str, Any] = { - "domains": {}, - "ssl": deepcopy(DEFAULT_SSL), - } - cfg = deepcopy(default_cfg) - if CONFIG_FILE.exists(): - try: - raw = load_json(CONFIG_FILE) - if raw: - from lib.common import deep_merge - - cfg = deep_merge(default_cfg, raw) - if "ssl" not in cfg: - cfg["ssl"] = deepcopy(DEFAULT_SSL) - except Exception: - pass - - # Build flattened domains list (one entry per path) - from lib.nginx import _resolve_paths as _ngx_resolve_paths - - 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 = _ngx_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 = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( - cfg - ) - - safe_cfg = strip_apply_meta(cfg) - nginx_pending_diff: list[dict[str, Any]] = [] - if pending_changes: - snap = cfg.get(_LAST_APPLIED_CONFIG_KEY) - if isinstance(snap, dict): - nginx_pending_diff = deep_diff(snap, safe_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) - - -# --------------------------------------------------------------------------- -# ACME collector -# --------------------------------------------------------------------------- - - -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. Legacy .account.conf (acme.sh v2.x) - account_path = acme_home / ".account.conf" - if account_path.is_file(): - 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//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 /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 _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: - from lib.acme import list_certs - - certs = list_certs() - except Exception as exc: - logger.warning("ACME state collection failed", exc_info=True) - certs = [] - cert_error = str(exc) - - account = _parse_account_conf() - - return { - "certs": certs, - "email": email, - "account": account, - "status": {"error": cert_error}, - "timestamp": _now_iso(), - } - - -register_collector("acme", _collect_acme) - - -# --------------------------------------------------------------------------- -# WireGuard collector -# --------------------------------------------------------------------------- - - -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. - """ - CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" - - DEFAULT_CONFIG: dict[str, Any] = { - "interface": { - "name": "wg0", - "listen_port": 51820, - "private_key": "", - "public_key": "", - "addresses": ["10.137.0.1/24"], - "server_endpoint": "", - "description": "", - "post_up": None, - "post_down": None, - }, - "access_classes": {}, - "peers": {}, - } - - from lib.common import deep_merge - - cfg: dict[str, Any] = deepcopy(DEFAULT_CONFIG) - if CONFIG_PATH.exists(): - try: - raw = load_json(CONFIG_PATH) - if raw: - cfg = deep_merge(deepcopy(DEFAULT_CONFIG), raw) - except Exception: - pass - - pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( - 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 - raw = res.stdout.strip() - current_peer: dict[str, Any] | None = None - class_peers: list[dict[str, Any]] = [] - cls_up = False - cls_iface: dict[str, Any] = {} - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - if line.startswith("interface:"): - cls_up = True - cls_iface = {} - current_peer = None - continue - if line.startswith("public key:"): - cls_iface["public_key"] = line.split(":", 1)[1].strip() - continue - if line.startswith("listening port:"): - cls_iface["listen_port"] = int(line.split(":", 1)[1].strip()) - continue - if line.startswith("peer:"): - cur_key = line.split(":", 1)[1].strip() - current_peer = { - "public_key": cur_key, - "endpoint": None, - "allowed_ips": [], - "latest_handshake": None, - "transfer_received": "0", - "transfer_sent": "0", - "persistent_keepalive": None, - } - class_peers.append(current_peer) - continue - if current_peer is None: - continue - if line.startswith("endpoint:"): - current_peer["endpoint"] = line.split(":", 1)[1].strip() - elif line.startswith("allowed ips:"): - current_peer["allowed_ips"] = ( - line.split(":", 1)[1].strip().split(", ") - ) - elif line.startswith("latest handshake:"): - current_peer["latest_handshake"] = line.split(":", 1)[1].strip() - elif line.startswith("transfer:"): - rest = line.split(":", 1)[1].strip().split(", ") - if rest: - current_peer["transfer_received"] = rest[0].strip() - if len(rest) > 1: - current_peer["transfer_sent"] = rest[1].strip() - elif line.startswith("persistent-keepalive:"): - with contextlib.suppress(ValueError): - current_peer["persistent_keepalive"] = int( - line.split(":", 1)[1].strip() - ) - status["classes"][class_key] = { - "up": cls_up, - "interface": cls_iface, - "peers": class_peers, - } - if cls_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") - legacy_peers: list[dict[str, Any]] = [] - current_peer: dict[str, Any] | None = None - res = run_proc(["wg", "show", ifname], sudo=True, check=False) - if res.returncode == 0: - raw = res.stdout.strip() - status["up"] = True - status["interface"] = {} - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - if line.startswith("interface:"): - status["interface"] = {} - current_peer = None - continue - if line.startswith("public key:"): - status["interface"]["public_key"] = line.split(":", 1)[1].strip() - continue - if line.startswith("listening port:"): - status["interface"]["listen_port"] = int( - line.split(":", 1)[1].strip() - ) - continue - if line.startswith("peer:"): - cur_key = line.split(":", 1)[1].strip() - current_peer = { - "public_key": cur_key, - "endpoint": None, - "allowed_ips": [], - "latest_handshake": None, - "transfer_received": "0", - "transfer_sent": "0", - "persistent_keepalive": None, - } - legacy_peers.append(current_peer) - continue - if current_peer is None: - continue - if line.startswith("endpoint:"): - current_peer["endpoint"] = line.split(":", 1)[1].strip() - elif line.startswith("allowed ips:"): - current_peer["allowed_ips"] = ( - line.split(":", 1)[1].strip().split(", ") - ) - elif line.startswith("latest handshake:"): - current_peer["latest_handshake"] = line.split(":", 1)[1].strip() - elif line.startswith("transfer:"): - rest = line.split(":", 1)[1].strip().split(", ") - if rest: - current_peer["transfer_received"] = rest[0].strip() - if len(rest) > 1: - current_peer["transfer_sent"] = rest[1].strip() - elif line.startswith("persistent-keepalive:"): - with contextlib.suppress(ValueError): - current_peer["persistent_keepalive"] = int( - line.split(":", 1)[1].strip() - ) - status["peers"] = legacy_peers - any_up = True - except Exception: - pass - - if any_up: - status["up"] = True - - status["pending_changes"] = pending_changes - pending_diff: list[dict[str, Any]] = [] - if pending_changes: - snap = cfg.get(_LAST_APPLIED_CONFIG_KEY) - if isinstance(snap, dict): - # `safe` has private keys stripped; drop any private-key paths so - # the pending summary never exposes key material. - pending_diff = [ - d for d in deep_diff(snap, safe) if "private_key" not in d["path"] - ] - status["pending_diff"] = pending_diff - 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", - } - ), -) - -# --------------------------------------------------------------------------- -# Networkd collector -# --------------------------------------------------------------------------- - - -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. - """ - CONFIG_PATH = PROJECT_DIR / "config" / "network" / "config.json" - - # Load config - net_cfg: dict[str, Any] = {} - if CONFIG_PATH.exists(): - with contextlib.suppress(Exception): - net_cfg = load_json(CONFIG_PATH) - - pending_changes = _APPLY_HASH_KEY not in net_cfg or net_cfg[ - _APPLY_HASH_KEY - ] != config_hash(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} - net_pending_diff: list[dict[str, Any]] = [] - if pending_changes: - snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY) - if isinstance(snap, dict): - net_pending_diff = deep_diff(snap, safe_net_cfg) - net_status["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", - } - ), -) - -# --------------------------------------------------------------------------- -# System metrics collector -# --------------------------------------------------------------------------- - - -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//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", - } - ), -) - - __all__ = [ + "PROJECT_DIR", + "_COLLECTORS", "_DEFAULT_POLL_INTERVALS", + "_VOLATILE", "State", "_diff_layers", + "_now_iso", "_strip_volatile", + "register_collector", "register_volatile", "state", ] diff --git a/lib/wireguard.py b/lib/wireguard.py index 2372628..9356ee5 100644 --- a/lib/wireguard.py +++ b/lib/wireguard.py @@ -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", diff --git a/tests/test_acme.py b/tests/test_acme.py index 7a23cfa..a069665 100644 --- a/tests/test_acme.py +++ b/tests/test_acme.py @@ -58,6 +58,20 @@ 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 must trail the subcommand args: acme.sh would otherwise + # consume the first subcommand arg as its (optional) file argument. + 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[-1] == "--log" + assert cmd.index("--issue") < cmd.index("--log") + assert "example.com" in cmd + class TestParseListOutput: def test_parses_single_entry(self): diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..7786437 --- /dev/null +++ b/tests/test_bootstrap.py @@ -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 diff --git a/tests/test_common.py b/tests/test_common.py index 7e72a5e..791a4f2 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -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 diff --git a/tests/test_firewall.py b/tests/test_firewall.py index b4810f2..1670fc8 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -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), @@ -1114,8 +1250,8 @@ class TestDaemonMutatorBaselineStamp: 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(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=[]) daemonfirewall.set_zone_interfaces( @@ -1138,8 +1274,8 @@ class TestDaemonMutatorBaselineStamp: daemonfirewall, "_parse_zone_output", return_value={"services": []} ), 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=[]) daemonfirewall.set_zone_services( @@ -1162,8 +1298,8 @@ class TestDaemonMutatorBaselineStamp: ): with ( 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=[]) daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True}) @@ -1182,8 +1318,8 @@ class TestDaemonMutatorBaselineStamp: would manufacture spurious service diffs on the next poll.""" with ( 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=[]) daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False}) @@ -1208,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() diff --git a/tests/test_network.py b/tests/test_network.py index baf7692..2d21956 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -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: diff --git a/tests/test_network_integration.py b/tests/test_network_integration.py index 78ec124..790cbd1 100644 --- a/tests/test_network_integration.py +++ b/tests/test_network_integration.py @@ -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 diff --git a/tests/test_nginx.py b/tests/test_nginx.py index dc09836..fb2d4e7 100644 --- a/tests/test_nginx.py +++ b/tests/test_nginx.py @@ -70,14 +70,28 @@ class TestGetConfig: # No churn: reading a current-format config leaves the file alone. assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before - def test_read_saves_when_migration_applied(self, temp_data_dir): - """get_config() persists the file when migration actually changes it.""" + 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. + # 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: diff --git a/tests/test_schema_types.py b/tests/test_schema_types.py index 81569a6..41b7e67 100644 --- a/tests/test_schema_types.py +++ b/tests/test_schema_types.py @@ -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,38 +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__: @@ -135,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"] @@ -148,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}" diff --git a/tests/test_state.py b/tests/test_state.py index 2356039..1e552e9 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -3,7 +3,9 @@ import json from unittest.mock import patch -import lib +import daemon.collectors.acme +import daemon.collectors.dnsmasq +import daemon.collectors.firewall from lib.state import State, state @@ -51,9 +53,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: @@ -88,10 +90,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: @@ -144,10 +146,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: @@ -168,11 +170,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() @@ -181,12 +183,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 = { @@ -226,7 +228,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) @@ -257,15 +261,19 @@ 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 lib.state import _collect_acme + from daemon.collectors.acme import _collect_acme 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", side_effect=RuntimeError("acme.sh failed with exit code 2"), ), - patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT), + patch.object( + daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT + ), ): result = _collect_acme() @@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal: assert "exit code 2" in result["status"]["error"] def test_success_reports_no_error(self): - from lib.state import _collect_acme + from daemon.collectors.acme import _collect_acme 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, "_parse_account_conf", return_value=_ACCOUNT), + patch.object( + daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT + ), ): result = _collect_acme() diff --git a/tests/test_wireguard.py b/tests/test_wireguard.py index 834bde0..4c7f8f9 100644 --- a/tests/test_wireguard.py +++ b/tests/test_wireguard.py @@ -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): diff --git a/webui/api/certs.py b/webui/api/certs.py index e9060b7..7b94885 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -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("/", methods=["GET"]) -def cert_details(domain: str): - """GET /api/certs/ — 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/", methods=["GET"]) -def issue_status(request_id: str): - """GET /api/certs/issue/ — 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("//renew", methods=["POST"]) -def renew_bp(domain: str): - """POST /api/certs//renew — start an (async) certificate renewal. - - Returns: - Response containing a renewal request ID (poll it at - ``/api/certs/renew/``) 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/", methods=["GET"]) -def renew_status(request_id: str): - """GET /api/certs/renew/ — 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("/", methods=["DELETE"]) -def remove_bp(domain: str): - """DELETE /api/certs/ — 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="/") +def cert_details(): + """GET /api/certs/ — 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/", params={"id": "request_id"} +) +def issue_status(): + """GET /api/certs/issue/ — Poll status of an issuance request.""" + + +@daemon_route(POST_ACME_RENEW, bp, rule="//renew") +def renew_bp(): + """POST /api/certs//renew — Start an (async) certificate renewal.""" + + +@daemon_route( + GET_ACME_RENEW_STATUS, bp, rule="/renew/", params={"id": "request_id"} +) +def renew_status(): + """GET /api/certs/renew/ — Poll status of a certificate renewal.""" + + +@daemon_route(DELETE_ACME_REMOVE, bp, rule="/", transform=void_transform) +def remove_bp(): + """DELETE /api/certs/ — 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.""" diff --git a/webui/api/common.py b/webui/api/common.py index 51fe7cb..21a27db 100644 --- a/webui/api/common.py +++ b/webui/api/common.py @@ -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": }`` / -``{"ok": false, "error": "msg"}``. +Used by all API blueprints to produce consistent JSON responses per the +API response contract (``{"ok": true, "data": }`` / +``{"ok": false, "error": "msg"}``) and to collapse the repetitive +``try: _ok(verb(EP, body)) except -> `` 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..{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..{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 diff --git a/webui/api/dhcp.py b/webui/api/dhcp.py index c72e026..847643c 100644 --- a/webui/api/dhcp.py +++ b/webui/api/dhcp.py @@ -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/", methods=["DELETE"]) -def remove_static_lease_bp(mac): - """DELETE /api/dhcp/static-lease/ — 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/", + transform=void_transform, +) +def remove_static_lease_bp(): + """DELETE /api/dhcp/static-lease/ — 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/", + transform=void_transform, +) +def remove_dns_record_bp(): + """DELETE /api/dhcp/dns-record/ — 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/", methods=["DELETE"]) -def remove_dns_record_bp(name): - """DELETE /api/dhcp/dns-record/ — 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.""" diff --git a/webui/api/firewall.py b/webui/api/firewall.py index 1da4540..2e028f8 100644 --- a/webui/api/firewall.py +++ b/webui/api/firewall.py @@ -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/", methods=["GET"]) -def zone_details(name: str): - """Retrieve details for a specific firewall zone. - - Endpoint: - GET /api/firewall/zones/ - - 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/", params={"zone": "name"} +) +def zone_details(): + """GET /api/firewall/zones/ — 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/", methods=["DELETE"]) -def delete_zone_bp(name: str): - """Delete a firewall zone by name. - - Endpoint: - DELETE /api/firewall/zones/ - - 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/", + params={"zone": "name"}, + transform=void_transform, +) +def delete_zone_bp(): + """DELETE /api/firewall/zones/ — Delete a firewall zone by name.""" # --------------------------------------------------------------------------- -# Zone interfaces +# Zone interfaces / services # --------------------------------------------------------------------------- -@bp.route("/zones//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//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//interfaces", + params={"zone": "name"}, + precheck=_interfaces_precheck, + transform=_zone_interfaces_echo, +) +def set_zone_interfaces_bp(): + """POST /api/firewall/zones//interfaces — Set a zone's interfaces.""" -# --------------------------------------------------------------------------- -# Zone services -# --------------------------------------------------------------------------- - - -@bp.route("/zones//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//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//services", + params={"zone": "name"}, + precheck=_services_precheck, + transform=_zone_services_echo, +) +def set_zone_services_bp(): + """POST /api/firewall/zones//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/", methods=["GET"]) -def list_rich_rules(zone: str): - """List rich rules for a specific firewall zone. - - Endpoint: - GET /api/firewall/rich-rules/ - - 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/") +def list_rich_rules(): + """GET /api/firewall/rich-rules/ — List rich rules for a zone.""" -@bp.route("/rich-rules//", 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// - - 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//", + params={"id": "rule_id"}, + transform=_rich_rule_remove_echo, +) +def remove_rich_rule_bp(): + """DELETE /api/firewall/rich-rules// — 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///", 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/// - - 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///", + transform=_forward_port_remove_echo, +) +def remove_forward_port_bp(): + """DELETE /api/firewall/forward-port/// — Remove a rule.""" diff --git a/webui/api/logs.py b/webui/api/logs.py index fb11203..c75dd5e 100644 --- a/webui/api/logs.py +++ b/webui/api/logs.py @@ -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) diff --git a/webui/api/network.py b/webui/api/network.py index d65c660..7bf8e57 100644 --- a/webui/api/network.py +++ b/webui/api/network.py @@ -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/", methods=["GET"]) -def get_interface(name: str): - """Get config + runtime state for a specific interface. - - Endpoint: - GET /api/network/interfaces/ - - 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/ — Config + runtime state for one interface.""" -@bp.route("/interfaces/", methods=["POST"]) -def save_interface(name: str): - """Save and apply network config for an interface. - - Endpoint: - POST /api/network/interfaces/ - - 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/ — Save and apply an interface's config.""" -@bp.route("/interfaces//reload", methods=["POST"]) -def reload_interface(name: str): - """Reload networkd for a single interface. - - Endpoint: - POST /api/network/interfaces//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//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.""" diff --git a/webui/api/proxy.py b/webui/api/proxy.py index 83757d9..c3ea3da 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -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/", methods=["PUT"]) -def update_domain_bp(domain): - """Update an existing proxy domain in-place. - - PUT /api/proxy/domains/ - - 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/", methods=["DELETE"]) -def remove_domain_bp(domain): - """Remove a proxy domain. - - DELETE /api/proxy/domains/ - - 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/", + methods=["PUT"], + precheck=_update_domain_precheck, + transform=_domain_echo, +) +def update_domain_bp(): + """PUT /api/proxy/domains/ — Update an existing proxy domain in-place.""" + + +@daemon_route( + DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/", transform=_domain_echo +) +def remove_domain_bp(): + """DELETE /api/proxy/domains/ — 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/", methods=["DELETE"]) -def remove_backend_bp(name): - """Remove a non-builtin backend. - - DELETE /api/proxy/backends/ - - 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/", transform=_backend_echo +) +def remove_backend_bp(): + """DELETE /api/proxy/backends/ — Remove a non-builtin backend.""" diff --git a/webui/api/status.py b/webui/api/status.py index ed6bc4c..1570422 100644 --- a/webui/api/status.py +++ b/webui/api/status.py @@ -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,97 +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 - Body: - {"force": true} (optional) — overrides the firewall safety guards - (management lockout, interface coverage) for this apply. - - Returns: - JSON response with applied subsystems list and any errors encountered. - """ - body = request.get_json(silent=True) - try: - return _ok(post(POST_STATUS_APPLY_ALL, body)) - 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.""" diff --git a/webui/api/wireguard.py b/webui/api/wireguard.py index abe0b10..6f979cc 100644 --- a/webui/api/wireguard.py +++ b/webui/api/wireguard.py @@ -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/", methods=["DELETE"]) -def remove_peer_bp(name): - """Remove a peer from the WireGuard configuration. - - Endpoint: DELETE /api/wireguard/peers/ - - 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/", transform=_peer_name_echo +) +def remove_peer_bp(): + """DELETE /api/wireguard/peers/ — 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//up", methods=["POST"]) -def class_up_bp(key): - """Bring up a single access class's WireGuard tunnel. - - Endpoint: POST /api/wireguard/classes//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//up", + params={"class_key": "key"}, + body={}, + transform=void_transform, +) +def class_up_bp(): + """POST /api/wireguard/classes//up — Bring up a class's tunnel.""" -@bp.route("/classes//down", methods=["POST"]) -def class_down_bp(key): - """Bring down a single access class's WireGuard tunnel. - - Endpoint: POST /api/wireguard/classes//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//down", + methods=["POST"], + params={"class_key": "key"}, + body={}, + transform=void_transform, +) +def class_down_bp(): + """POST /api/wireguard/classes//down — Bring down a class's tunnel.""" -@bp.route("/classes//status", methods=["GET"]) -def class_status_bp(key): - """Get status for a single access class's tunnel. - - Endpoint: GET /api/wireguard/classes//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//status", + params={"class_key": "key"}, +) +def class_status_bp(): + """GET /api/wireguard/classes//status — Get status for a class's tunnel.""" -@bp.route("/classes/keys/", methods=["POST"]) -def class_init_keys_bp(key): - """Generate key pair for a single access class. - - Endpoint: POST /api/wireguard/classes/keys/ - """ - 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/", + params={"class_key": "key"}, + body={}, + transform=void_transform, +) +def class_init_keys_bp(): + """POST /api/wireguard/classes/keys/ — Generate keys for a class.""" diff --git a/webui/static/hoover/components/applyconfirm.js b/webui/static/hoover/components/applyconfirm.js index 25ad69f..62be87d 100644 --- a/webui/static/hoover/components/applyconfirm.js +++ b/webui/static/hoover/components/applyconfirm.js @@ -151,7 +151,7 @@ async function openApplyModal(successMsg) { ${fwPending ? html`` : ''}
`);