refactor: daemon collectors, thin webui proxies, pure config reads

- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+24 -3
View File
@@ -39,10 +39,15 @@ Basic auth (`.htpasswd`) renders **only** for proxy domains whose
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling. - `daemon/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/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/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/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/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. - `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`). - `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments). - `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 <cmd>` to apply. Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code. Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
**Config reads are pure.** Every `lib/<subsystem>.get_config()` is a side-effect-free
read (returns in-memory defaults when the file is missing; nginx applies its
legacy-format migration in memory). State collectors therefore never write to
disk — filesystem setup (runtime dirs, one-shot nginx migration) happens once at
daemon startup in `lib/bootstrap.py` (after `system_import.import_all()`).
**Firewall interface-coverage invariant.** Every network-managed interface
(`lo`/`wg*` excluded) must be covered by a zone in `config/firewall/config.json`
or listed under the top-level `unmanaged` key. The config is the source of truth
for zone interfaces (an omitted `interfaces` key = empty list; no hands-off
zones). Enforced at save time (`POST`/`PATCH /firewall/config` → 400) and apply
time (`POST /firewall/config/apply` → 409, `force: true` overrides) via the pure
`lib.firewall.validate_coverage()`. Live drift is advisory only
(`uncovered_interfaces` state field). See `docs/config.md`.
## API Response Contract ## API Response Contract
- Success: `{"ok": true, "data": <value>}``_ok(data)` (Flask) or `ok(data)` (aiohttp) - Success: `{"ok": true, "data": <value>}``_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`. **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 ```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint .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/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/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/hoover.md` | Custom frontend framework API reference | | `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 | | `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
-289
View File
@@ -1,289 +0,0 @@
# Hardening Plan — Firewall Zone Handling (post DHCP-incident)
Status: ready to implement (line refs re-verified against code 2026-08-28; review
corrections applied 2026-08-28; full cross-file re-verification with minor clarifications
applied 2026-08-28).
Background: LAN clients stopped receiving DHCP leases because the `internal` zone lost its
`eth1` assignment (in both live firewalld and `config/firewall/config.json` — incident-time
state; the appliance was recovered before this plan was written, so the repo's
`config.json` now shows `internal: eth1` again, per Sequencing item 4). With no zone
covering `eth1`, all inbound traffic hit the default `reject` policy — DHCP (and everything
else) from the LAN was dropped before reaching a healthy dnsmasq. Nothing flagged the
uncovered-interface condition; every apply silently reinforced it. Follow-ups below close
that blind spot and the related sharp edges. `/etc/sudoers.d/wall` stays as-is (dev privs,
user-acknowledged).
Decisions (confirmed):
- Target drift: **Option A** — absent `target` = unmanaged (not diffed, not touched by apply).
The absent key is the *single* canonical form for "unmanaged". A zone whose `target`
normalizes to `default` (e.g. the legacy explicit `"DEFAULT"`) is treated as unmanaged in
the pending diff as well — it mirrors `_config_apply`, which only ever sets
`ACCEPT/DROP/REJECT`. No backward compatibility is needed.
- Coverage guard: **ConflictError + `force`** in `_config_apply`; UI confirm in the
interfaces picker when an interface's last zone is dropped (zone *deletion* is out of
scope — `delete_zone` stays unguarded).
- Guard scope: **explicit `lo`/`wg*` filter** — guarded ifaces = network-config keys minus
`lo` and `wg*` prefixes (regardless of config contents); the coverage union also counts
live interfaces of zones absent from config (apply never touches them).
- Config cleanup is applied via **`POST /api/firewall/config` (full replace) + apply**, not
a raw JSON edit (re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent). `PATCH` cannot be used: `deep_merge` (lib/common.py:244-256)
has no key-removal path, so the current `patch_config` endpoint cannot delete the
`target` keys (a `null` value would be written instead).
- Execution: **parallel streams** — WI-1 (A/B/C) + WI-3 are file-disjoint and run concurrently;
WI-2 → WI-4 → WI-5 stay serial (shared files: `_config_apply`, `_compute_pending_changes`,
`tests/test_firewall.py`, `docs/config.md`).
- Pin `"target": "ACCEPT"` for `internal` in config (declared intent = trusted LAN).
Extra latent bugs found during research (folded in):
- `create_zone` (daemon/handlers/firewall.py:655-663) never creates the zone at all: the
`--new-zone` step is missing and it runs `firewall-cmd --set-target=<target>` on a
nonexistent zone. For the default target it would run `--set-target=default`, which the
codebase's defensive guards treat as un-settable (the new-zone branch of
`_config_apply`, lines 188-205, already guards this; `create_zone` does not). The
current firewalld man page (verified via live docs 2026-08-28) lists `default` as an
accepted `--set-target` value for zones; the planned defensive skip of default targets
is still correct under Option A. An appliance-side check of actual behavior is optional
during live verification, not required.
- `_compute_pending_changes` (lib/firewall.py:380-381) skips entire zones with no
`interfaces` key — no field drift is ever reported for such zones.
- The explicit `"target": "DEFAULT"` entries in `config/firewall/config.json` (public,
vpn-full, work) are a one-shot install-time import artifact:
`system_import.import_firewall` (lib/system_import.py:921-932) emits a `target` key for
every imported zone with interfaces, and `_live_target_to_config` maps live `default` to
the config token `"DEFAULT"` (lib/firewall.py:353-361). No other code path writes
`target` into config. Hand/UI-edited zones (e.g. `internal`) omit the key — two
notations for the same meaning. Fixed by WI-2.
---
## WI-1 — Interface-coverage invariant + apply guard (core fix)
Goal: an interface managed by the network subsystem that ends up in *no* zone becomes a loud,
always-visible condition, and bulk-apply cannot silently produce it.
1. `daemon/handlers/firewall.py`, `_config_apply` (lines 138-403):
- Interfaces step (lines 252-279) — new semantics:
- **key absent → hands off**: skip the remove-then-add for that zone (matches the
existing "None = don't change" masquerade semantic at line 281).
- **explicit `[]` → intentional unassign-all** (the UI picker legitimately sends this).
- **Pre-mutation coverage guard**: compute post-apply coverage = union of each zone's
desired interfaces (explicit list if key present, else its *current live* set for
absent-key zones) **plus the live interfaces of zones absent from config** (apply
never touches those; without this the guard false-positives when a live-only zone
still holds an interface). Guarded interfaces = keys of
`lib.network.get_config()["interfaces"]` with `lo` and `wg*` prefixes **explicitly
filtered** (vpn zones are managed by `WgToFirewallSync`; `lo` is normally zoneless —
without the filter a networkd-managed `lo` would make every apply raise). If any
guarded interface would be uncovered → `ConflictError`
naming the interfaces and the consequence (clients lose connectivity/DHCP), overridable
with `body.force = true` (same pattern as the https/ssh lockout at lines 154-169; the
`config_apply` handler at 608-631 already receives `body`). Implementation note: the
guard needs current live interfaces for *all* zones before any mutation — take them
from one `firewall-cmd --get-active-zones` call before the zone loop (same pattern as
`set_zone_interfaces`, daemon/handlers/firewall.py:731-732); do not rely on the
per-zone `--list-all` reads that currently happen inside the mutation loop.
Place the guard alongside the https/ssh lockout check (lines 154-169), i.e. before
the pre-apply backup write — the existing lockout test
(`test_config_apply_blocks_lockout_before_backup`, tests/test_firewall.py:670-677)
pins `mock_backup.assert_not_called()`, and the new guard must respect the same
no-side-effect-on-conflict invariant. Update the `_config_apply` and `config_apply`
docstrings (both currently document only the lockout guard + `force`).
2. `set_zone_interfaces` (lines 706-791): when the new selection leaves an interface in *no*
zone, emit a prominent `logger.warning`. No block — deliberate UI action.
3. `lib/firewall.py`, `_compute_pending_changes` (lines 364-476): remove the blanket
`if not zone_cfg.get("interfaces"): continue` (line 380-381) — only gate the *interfaces*
diff on key presence; report services/target/masquerade/rules/fwd-ports drift for such
zones as today. Implementation detail: the interfaces diff itself must be gated —
`cfg_ifaces = set(zone_cfg.get("interfaces", []))` (line 383) would otherwise diff
`set()` against live for absent-key zones and emit a spurious entry; compute it only
when `"interfaces" in zone_cfg`. Behavior change: zones with `interfaces: []`
(live: `vpn`, `vpn-full`, `work`) will now report field drift on every poll, and
config zones absent from live entirely will diff against an empty zone — the
pending list may be non-empty immediately after merge.
4. `lib/state.py`, `_collect_firewall` (line 449+): compute `uncovered_interfaces`
(network-config ifaces not in any live zone) on every poll, so it is visible even with
zero pending changes. Apply the same `lo`/`wg*` filter as the WI-1.1 guard. A
network-config iface that is absent from live state entirely (down/renamed) counts as
uncovered too — not just zoneless-on-live. Add
`uncovered_interfaces: list[str]` to `schema.FirewallState` (lib/schema.py:87); the
TypedDict is shape-checked against the collector return by
`tests/test_schema_types.py::test_firewall_state` (lines 21-54), so the collector must
always include the key. Update the `FirewallState` block in `docs/state-model.md`
(lines 51-78 — the authoritative Markdown reference per lib/schema.py:3-5). Test
note: the network-config read is a file read, not `run()` — patch
`lib.network.get_config` in `test_firewall_state` (which mocks only `lib.state.run`)
so the value is deterministic. This is the detection that would have caught the
incident within 30s.
5. Surface it:
- `daemon/handlers/status.py` (lines 62-105): advisory coverage warnings in the firewall
section of `/api/status/pending` (not counted in `needs_apply`).
- `webui/static/pages/zones.js`: warning banner from
`state.firewall.data.uncovered_interfaces`.
- `docs/api.md` (line 1965): document the new advisory field in the
`/api/status/pending` firewall section (not counted in `needs_apply`/`total_changes`).
- `docs/api.md`: note that `POST /api/status/apply-all` runs the firewall apply with
`force=false` — a coverage `ConflictError` surfaces in the response `errors` dict
under "Firewall" while the other subsystems proceed (the desired no-silent-apply
behavior).
6. UI guard: the interfaces `MultiSelectModal` in zones.js gets a `confirm` hook (same
pattern as the services lockout at lines 89-99): if the selection would drop an
interface's last zone, warn that clients on that segment lose connectivity and DHCP.
Tests: `tests/test_firewall.py` — absent-key zone keeps live interfaces on apply; explicit
`[]` unassigns; conflict raised when a network iface goes uncovered; `force` bypasses;
guard ignores `lo`/`wg*` even when present in network config; live-only-zone interfaces
count as covered; pending diff now reports services drift on interface-less zones
(absent-key zones emit no spurious interfaces entry). `tests/test_api.py` /
`tests/test_status_pending.py` for the new advisory field. `tests/test_schema_types.py`
for the new `FirewallState` key. Test setup note: the guard reads
`lib.network.get_config()` — a file read, not `run()` — so every non-force `_config_apply`
test must patch `lib.network.get_config` (e.g. return `{"interfaces": {"eth0": {}}}`
matching the mocked live state). Without it, the repo's real
`config/network/config.json` (carries `eth0`+`eth1`) combined with the mocked `run`
(one return string for all calls, so `--get-active-zones` does not cover `eth1`)
raises a spurious `ConflictError`; existing tests affected include
`test_applies_existing_zone` (~560) and `test_stamps_applied_baseline` (~750).
The patch value must be consistent with the mocked live state **per test**:
`{"interfaces": {"eth0": {}}}` only works where the config driving the guard (the
`lib.firewall.get_config` mock) carries an explicit `interfaces` list
(`test_stamps_applied_baseline`, `_STAMP_TEST_CFG` with `"interfaces": ["eth0"]`) —
the explicit list covers eth0 in the post-apply union. In `test_applies_existing_zone`
that mock also carries `"interfaces": ["eth0"]` (the `{"public": {}}` mock is
`_get_config`, used only for the end-of-apply stamp at firewall.py:396), so
`{"interfaces": {"eth0": {}}}` works there too — but the single-string `run` mock
makes `--get-active-zones` parse to garbage covering neither eth0 nor eth1, so the
simplest patch is `{"interfaces": {}}` (or upgrade the `run` mock to a `side_effect`
answering `--get-active-zones` with eth0 covered). Guard ordering: the
https/ssh lockout check (lines 154-169) must run **before** the coverage guard —
`test_config_apply_blocks_lockout_before_backup` (~670) asserts the "https and ssh"
message and mocks neither `run` nor the network config, so a coverage guard evaluated
first would hit the un-mocked `run`/file read and break that test.
## WI-2 — Target-drift semantics (Option A: omit = unmanaged)
Goal: stop the trap where config omits `target` (→ implicit "default"), live says `ACCEPT`,
the pending diff flags it, and apply can never clear it (firewalld cannot set "default" back)
→ permanent fake "pending" + dead apply button.
- `lib/firewall.py`, `_compute_pending_changes`: skip the target diff when the zone config
has no explicit `target` key **or** the value normalizes to `default` (precedent: the
public-masquerade skip at lines 419-424). Defensive — it covers legacy configs still
carrying explicit `"DEFAULT"`. `_config_apply` already leaves default targets alone
(lines 207-219). Explicit `ACCEPT/DROP/REJECT` remains fully managed.
- Fix the source — `lib/system_import.py`, `import_firewall` (lines 921-932): emit the
`"target"` key only when the imported zone's live target normalizes to something other
than `default`. Today the importer emits a faithful snapshot with a `target` key for
every zone, and `_live_target_to_config` maps live `default``"DEFAULT"` (this is the
sole author of the explicit `"target": "DEFAULT"` entries; no other code path writes
`target` into config). Update `tests/test_system_import.py` (assertions at lines
538-574). Keep `_live_target_to_config` itself (lib/firewall.py:353-361; still asserted
at tests/test_firewall.py:148-149).
- `create_zone` (daemon/handlers/firewall.py:632-670): rewrite the body to mirror the full
`_config_apply` new-zone branch (lines 188-205): run `--new-zone` first (currently
missing entirely — the endpoint would create no zone at all), then `--set-target` only
when the target normalizes to something other than `default`, then `_reload()`.
- Config cleanup (on the appliance): remove the legacy `"target": "DEFAULT"` entries from
the `public`, `vpn-full`, and `work` zones (making key-absence the one canonical
"unmanaged" notation), and add `"target": "ACCEPT"` to the `internal` zone — declares the
trusted-LAN intent and makes future apply enforce it and flag any drift. Apply via
`POST /api/firewall/config` (full replace) + apply, **not** a raw JSON edit and **not**
`PATCH` (which cannot delete keys — see the Decisions note above): GET the current
config, drop the three `target` entries, add `"target": "ACCEPT"` to `internal`, POST,
then apply. Apply re-stamps `_last_applied_hash`/`_last_applied_config` so cancel-all
baselines stay consistent.
- docs/config.md: document "target omitted (or normalizes to `default`) → live value is
preserved, not diffed, and never re-set by apply".
Tests: pending-diff cases (absent target ⇒ no target entry; explicit `"DEFAULT"` ⇒ no
target entry; explicit `ACCEPT` vs live `default` ⇒ entry); `create_zone` paths
(`--new-zone` always called; `--set-target` only for non-default targets);
`import_firewall` omits `target` for default-target zones while keeping it for
`ACCEPT/DROP/REJECT`.
## WI-3 — Make the sync bus non-destructive (stale DHCP ranges)
Goal: a zone interface change must never delete user data. `FirewallToDhcpSync` currently
hard-deletes ranges the moment an interface loses zone coverage (lib/sync.py:806-822) —
exactly what ate the eth1 pool during the incident's mis-click.
- `lib/sync.py`, `FirewallToDhcpSync.on_firewall_config_saved` (lines 804-822):
- Keep the range in the dnsmasq config.
- Log a warning and emit a `SyncResult.changes` entry: "DHCP range on '<iface>' has no
firewall zone coverage — inactive until a zone covers it".
- Report `dnsmasq` in `affected_subsystems` only when the gateway auto-fill step
(lines 824-855) actually mutated config — the return at sync.py:866 becomes
`["dnsmasq"] if changed else []`.
- Update the class docstring (lines 742-748) and method docstring accordingly.
- Rationale: a range is inert only while the firewall drops the traffic; keeping it makes
zone re-assignment self-heal with zero follow-up.
Tests: `tests/test_sync.py` `TestFirewallToDhcpSync` (lines 763-920): `test_removes_stale_ranges`
becomes `test_flags_uncovered_range_without_deleting` (assert dnsmasq config untouched +
warning present). `test_keeps_global_ranges` also asserts the stale eth2 range is removed
(`len(saved_ranges) == 1`, `dnsmasq` affected, `mock_dm_save.call_args` read
unconditionally) and must be rewritten for the non-destructive semantics (both ranges kept,
no save, no affected subsystems, warning present).
## WI-4 — Make the firewall "backup" real
Goal: `data/firewall/rules.json` stores an empty skeleton before *and* after apply
(daemon/handlers/firewall.py:171-179, 385-393), so the disaster-recovery artifact promised by
docs/architecture.md:134 contains nothing.
- `_config_apply`: save a **pre-apply snapshot only**, before any mutation:
`{timestamp, default_zone, zones: _parse_all_zones_output(firewall-cmd --list-all-zones --permanent),
config: <config.json contents>}``data/firewall/rules.json` via `_save_backup`. The
permanent view is what is reproducible for manual recovery. Note: `_parse_all_zones_output`
must be added to the handler's `lib.firewall` import (daemon/handlers/firewall.py:38-43) —
it is not currently imported there.
- Remove the misleading post-apply skeleton write (lines 385-393); the apply response's
`backup` path field is unchanged. `load_backup` has no live consumers — no API changes.
- Docs: update architecture.md:134/298, config.md:485, overview.md:77 to describe the shape.
Tests: the `_save_backup` patch sites in `tests/test_firewall.py` (~lines 566, 701,
751) carry `return_value="/tmp/rules.json"` and stay valid as-is — no existing test
asserts on its arguments or call count. Optionally add one assertion that the single
pre-apply call receives the snapshot payload (`default_zone`/`zones`/`config` keys).
## WI-5 (optional, low) — Daemon shutdown noise
"Task was destroyed but it is pending" + logging-error tracebacks on daemon SIGTERM (7
occurrences since the Aug 22 restart, still happening on current code).
- `daemon/server.py` shutdown path: stop accepting new connections, give in-flight request
tasks a bounded grace period (`await server.wait_closed()` with timeout) before
`runner.cleanup()`; suppress the asyncio default exception handler during the teardown
window.
Cosmetic. Do last, or defer.
---
## Sequencing & verification
1. One branch; one commit per WI: **1 → 3 → 2 → 4 → 5**. WI-1 + WI-3 together fix the
incident class; WI-2/WI-4 are hygiene; WI-5 optional.
Execution: **Phase 1 in parallel** (file-disjoint streams — A: WI-1 backend,
`daemon/handlers/firewall.py` + `lib/firewall.py` pending-diff; B: WI-1 state surface,
`lib/state.py` + `lib/schema.py` + `daemon/handlers/status.py` + schema/status tests +
`docs/state-model.md` + `docs/api.md`;
C: WI-1 frontend, `webui/static/pages/zones.js`; D: WI-3, `lib/sync.py` +
`tests/test_sync.py`); **Phase 2 serial** — WI-2 → WI-4 → WI-5 (shared files:
`_config_apply`, `_compute_pending_changes`, `tests/test_firewall.py`, `docs/config.md`).
2. Per commit:
- `.venv/bin/ruff check lib/ webui/ daemon/ tests/`
- `.venv/bin/ruff format lib/ webui/ daemon/ tests/`
- `.venv/bin/python -m pytest tests/ -v`
- `node tests/test-*.js` for touched hoover components (zones.js itself has no node test
file — verify by loading the page in the running UI).
3. Live verification on the appliance after merge:
- Confirm `uncovered_interfaces` is empty in firewall state.
- Check the firewall pending list for the expected post-WI-1.3/WI-2 drift entries
(`interfaces: []` zones now report field drift; `internal` target pinned to
`ACCEPT`) and confirm nothing unexpected appears.
- Optional drill: create a throwaway zone and move `eth0` onto it via the API with
`force` omitted (expect ConflictError) and added (expect success + warning), then
restore. Skip if undesired — mocked tests cover the logic.
4. No live-system changes during implementation; DHCP/zone state stays as the operator left
it (internal=eth1, public=eth0, leases confirmed 01:29).
+26
View File
@@ -0,0 +1,26 @@
"""State collectors for vacuum-walld.
Importing this package registers every collector with the ``lib.state``
store (registration side effect). Import it before the first
``populate()``/``poll()`` call.
"""
from daemon.collectors import (
acme,
dnsmasq,
firewall,
networkd,
nginx,
system,
wireguard,
)
__all__ = [
"acme",
"dnsmasq",
"firewall",
"networkd",
"nginx",
"system",
"wireguard",
]
+165
View File
@@ -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/<server>/account.json) — we can't reliably parse those without
# walking the directory, so fall back to the declarative config
# which the handlers keep in sync.
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
project_root = acme_home.parent.parent # data/acme → data → project root
acme_cfg = project_root / "config" / "acme" / "config.json"
data = load_json(acme_cfg)
email = (data.get("email") or "").strip()
ca_raw = (data.get("ca") or "").strip()
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": None,
}
except (OSError, ValueError):
pass
return default
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
from lib.acme import _read_acme_email
return _read_acme_email()
def _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)
+85
View File
@@ -0,0 +1,85 @@
"""DNSMasq state collector."""
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.dnsmasq import DEFAULT_CFG, get_config
from lib.state import _now_iso, register_collector
def _collect_dnsmasq() -> schema.DnsmasqState:
"""Collect dnsmasq status, config, and leases.
Returns:
Dict containing config, service status, leases, and timestamp.
"""
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
# Load config (lib defaults; fall back to them when the file is broken)
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CFG)
# Service status
service_active = False
try:
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
service_active = proc.stdout.strip() == "active"
except Exception:
pass
# Leases
leases: list[dict[str, Any]] = []
try:
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
leases.append(
{
"expires": ts.isoformat() if ts else "",
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
)
except Exception:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
pending_changes, pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
}
register_collector("dnsmasq", _collect_dnsmasq)
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
+175
View File
@@ -0,0 +1,175 @@
"""Firewall state collector (read-only sudo queries)."""
import contextlib
from typing import Any
from lib import schema
from lib.common import load_json, run, strip_apply_meta
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
get_service_descriptions,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import get_config as _network_get_config
from lib.state import (
PROJECT_DIR,
_now_iso,
register_collector,
register_volatile,
)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a port-forward dict to a compact string representation.
Args:
fp: Port-forward entry containing port and proto keys.
Returns:
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
"""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
return "/".join(parts)
def _collect_firewall() -> schema.FirewallState:
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
iface_map: dict[str, dict[str, Any]] = {}
for line in link_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
iface_state = parts[i + 1]
if p == "mtu" and i + 1 < len(parts):
mtu = int(parts[i + 1])
if p.startswith("link/ether") and i + 1 < len(parts):
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"mac": mac,
"state": iface_state,
"mtu": mtu,
"ips": [],
"ipv6": [],
"zone": None,
}
for line in addr_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1].split("@")[0]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
for entry in iface_map.values():
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
ifaces = list(iface_map.values())
# Collect all zones in a single call (replaces per-zone loop)
zones: dict[str, dict[str, Any]] = {}
try:
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
zones = _parse_all_zones_output(all_zones_raw)
except Exception:
pass
# Load config (strip apply bookkeeping keys, as the other collectors do)
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = strip_apply_meta(load_json(fw_config_path))
# Pending changes
full_state = {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"timestamp": _now_iso(),
}
pending = {}
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
net_cfg: dict[str, Any] = {}
with contextlib.suppress(Exception):
net_cfg = _network_get_config()
covered: set[str] = set()
for zone_ifaces in active.values():
covered.update(zone_ifaces)
for zone in zones.values():
covered.update(zone.get("interfaces", []))
uncovered_interfaces = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg") and name not in covered
]
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
# Parsed from the firewalld service XML definitions; cached per
# process so the 30s poll does not re-read the files.
"service_descriptions": get_service_descriptions(),
"uncovered_interfaces": uncovered_interfaces,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
"pending": pending,
"timestamp": _now_iso(),
}
register_collector("firewall", _collect_firewall)
register_volatile(
"firewall",
frozenset(
{
"interfaces[].ips",
"interfaces[].ipv6",
}
),
)
+67
View File
@@ -0,0 +1,67 @@
"""Networkd state collector."""
from typing import Any
from lib import schema
from lib.common import compute_pending, run, strip_apply_meta
from lib.network import get_config, parse_networkctl_status
from lib.state import _now_iso, register_collector, register_volatile
def _collect_networkd() -> schema.NetworkdState:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output,
config, and pending changes status.
"""
# Load config
try:
net_cfg = get_config()
except Exception:
net_cfg = {}
pending_changes, net_pending_diff = compute_pending(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {
"pending_changes": pending_changes,
"pending_diff": net_pending_diff,
}
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
+66
View File
@@ -0,0 +1,66 @@
"""Nginx state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, strip_apply_meta
from lib.nginx import DEFAULT_CONFIG, SITES_DIR, _resolve_paths, get_config
from lib.state import _now_iso, register_collector
def _collect_nginx() -> schema.NginxState:
"""Collect nginx config and domains list.
Returns:
Dict containing config, domains, and timestamp.
"""
# Load config via lib.nginx — a pure read that applies the legacy-format
# migration in memory (the one-shot on-disk migration runs at daemon
# startup, see lib.bootstrap).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
# Build flattened domains list (one entry per path)
backends = cfg.get("backends", {})
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = _resolve_paths(dom, backends)
if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
"path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
"cert": dom.get("cert"),
}
if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
domains.append(entry)
pending_changes, nginx_pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"domains": domains,
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
register_collector("nginx", _collect_nginx)
+125
View File
@@ -0,0 +1,125 @@
"""System metrics collector."""
from pathlib import Path
from typing import Any
from lib import schema
from lib.state import _now_iso, register_collector, register_volatile
def _parse_meminfo() -> dict[str, Any]:
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
info: dict[str, int] = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
parts = value.strip().split()
val = int(parts[0])
# Convert kB to bytes
if parts and parts[-1] == "kB":
val *= 1024
info[key] = val
except (OSError, ValueError):
return {}
return info
def _collect_system() -> schema.SystemState:
"""Collect system-wide metrics: CPU load, memory, network traffic.
Reads from /proc and /sys — no subprocess needed.
Returns:
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
"""
# CPU load
loads = []
try:
parts = Path("/proc/loadavg").read_text().split()
loads = [float(x) for x in parts[:3]]
except (OSError, ValueError):
loads = [0.0, 0.0, 0.0]
# Memory
meminfo_raw = _parse_meminfo()
mem_total = meminfo_raw.get("MemTotal", 0)
mem_free = meminfo_raw.get("MemFree", 0)
mem_available = meminfo_raw.get("MemAvailable", mem_free)
mem_buffers = meminfo_raw.get("Buffers", 0)
mem_cached = meminfo_raw.get("Cached", 0)
mem_used = mem_total - mem_free - mem_buffers - mem_cached
if mem_used < 0:
mem_used = mem_total - mem_available
# Swap
swap_total = meminfo_raw.get("SwapTotal", 0)
swap_free = meminfo_raw.get("SwapFree", 0)
swap_used = swap_total - swap_free
# Network traffic from /sys/class/net/<iface>/statistics/
traffic: dict[str, dict[str, int]] = {}
try:
net_root = Path("/sys/class/net")
if net_root.is_dir():
for iface_dir in net_root.iterdir():
stats_dir = iface_dir / "statistics"
if not stats_dir.is_dir():
continue
iface_name = iface_dir.name
rx_bytes = 0
tx_bytes = 0
rx_packets = 0
tx_packets = 0
try:
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
except (OSError, ValueError):
continue
traffic[iface_name] = {
"rx_bytes": rx_bytes,
"tx_bytes": tx_bytes,
"rx_packets": rx_packets,
"tx_packets": tx_packets,
}
except OSError:
pass
return {
"load": {
"load1": loads[0],
"load5": loads[1],
"load15": loads[2],
},
"memory": {
"total": mem_total,
"available": mem_available,
"used": mem_used,
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
},
"swap": {
"total": swap_total,
"used": swap_used,
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
},
"traffic": traffic,
"timestamp": _now_iso(),
}
register_collector("system", _collect_system)
register_volatile(
"system",
frozenset(
{
"load",
"memory",
"swap",
"traffic",
}
),
)
+125
View File
@@ -0,0 +1,125 @@
"""WireGuard state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.state import _now_iso, register_collector, register_volatile
from lib.wireguard import DEFAULT_CONFIG, get_config, parse_wg_show_output
def _collect_wireguard() -> schema.WgState:
"""Collect WireGuard config, per-class status, and peers.
Returns:
Dict containing interface config, per-class runtime status,
combined peers, and overall tunnel status.
"""
# Load config via lib.wireguard defaults (which include the built-in
# full/internet access classes).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
pending_changes, pending_diff = compute_pending(cfg)
# Safe config (strip private keys from interface and access classes)
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
safe["access_classes"] = {}
for ck, cv in cfg.get("access_classes", {}).items():
if isinstance(cv, dict):
entry = dict(cv)
entry.pop("private_key", None)
safe["access_classes"][ck] = entry
# Peers list (safe)
peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
peers.append(entry)
# Runtime status — per-class interfaces
status: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
classes = cfg.get("access_classes", {})
any_up = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not isinstance(class_cfg, dict):
continue
ifname = f"wg-{class_key}"
try:
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode != 0:
status["classes"][class_key] = {
"up": False,
"interface": {},
"peers": [],
}
continue
parsed = parse_wg_show_output(res.stdout.strip())
status["classes"][class_key] = {
"up": parsed["up"],
"interface": parsed.get("interface", {}),
"peers": parsed.get("peers", []),
}
if parsed["up"]:
any_up = True
except Exception:
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
# Also collect legacy single-interface status
try:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = parse_wg_show_output(res.stdout.strip())
status["up"] = True
status["interface"] = parsed.get("interface", {})
status["peers"] = parsed.get("peers", [])
any_up = True
except Exception:
pass
if any_up:
status["up"] = True
status["pending_changes"] = pending_changes
# Drop any private-key paths so the pending summary never exposes
# key material.
status["pending_diff"] = [d for d in pending_diff if "private_key" not in d["path"]]
return {
"config": safe,
"status": status,
"peers": peers,
"timestamp": _now_iso(),
}
register_collector("wireguard", _collect_wireguard)
register_volatile(
"wireguard",
frozenset(
{
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
"status.classes[].peers[].transfer_received",
"status.classes[].peers[].transfer_sent",
"status.classes[].peers[].latest_handshake",
}
),
)
+5 -5
View File
@@ -592,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
except (FileNotFoundError, subprocess.TimeoutExpired): except (FileNotFoundError, subprocess.TimeoutExpired):
pass pass
from lib.state import _parse_account_conf from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME) info = _parse_account_conf(_ACME_HOME)
if info.get("registered"): if info.get("registered"):
@@ -607,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
def _check_account_registered() -> tuple[bool, str]: def _check_account_registered() -> tuple[bool, str]:
"""Blocking check: verify an ACME account is registered. """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 the legacy .account.conf and the declarative config/acme/config.json
used by modern acme.sh (v3.x). 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) info = _parse_account_conf(_ACME_HOME)
if info.get("registered"): if info.get("registered"):
@@ -622,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
def _get_account_info() -> dict[str, Any]: def _get_account_info() -> dict[str, Any]:
"""Read and return the ACME account info dict. """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. source of truth.
""" """
from lib.state import _parse_account_conf from daemon.collectors.acme import _parse_account_conf
return _parse_account_conf(_ACME_HOME) return _parse_account_conf(_ACME_HOME)
+26
View File
@@ -0,0 +1,26 @@
"""Shared helpers for daemon handlers."""
from typing import Any
from daemon.server import refresh_state
from lib.sync import SyncEvent, bus
def emit_and_refresh(
subsystem: str, payload: dict[str, Any] | None = None
) -> list[str]:
"""Emit a ``config_saved`` sync event and refresh the affected state.
All mutation handlers end with the same tail: emit the event, refresh
the source subsystem plus every subsystem the sync touched.
Args:
subsystem: Source subsystem name.
payload: Event payload (e.g. ``{"action": "zone_created"}``).
Returns:
Subsystems affected by the sync event.
"""
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
refresh_state([subsystem, *sync_result.affected_subsystems])
return sync_result.affected_subsystems
+16 -75
View File
@@ -8,6 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from daemon.handlers.common import emit_and_refresh
from daemon.iface import ( from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE, DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE, DELETE_DNSMASQ_RANGES_REMOVE,
@@ -24,7 +25,7 @@ from daemon.iface import (
POST_DNSMASQ_STATIC_LEASE_ADD, POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS, POST_DNSMASQ_UPSTREAMS,
) )
from daemon.server import NotFoundError, refresh_state, registry from daemon.server import NotFoundError, registry
from lib.common import ( from lib.common import (
deep_merge, deep_merge,
ensure_dirs, ensure_dirs,
@@ -35,7 +36,6 @@ from lib.common import (
stamp_applied, stamp_applied,
strip_apply_meta, strip_apply_meta,
) )
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
_save_config(body) _save_config(body)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "config_saved"})
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True} return {"config_saved": True}
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "config_patched"})
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True} return {"config_saved": True}
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_config() cfg_after = _get_config()
stamp_applied(cfg_after) stamp_applied(cfg_after)
_save_config(cfg_after) _save_config(cfg_after)
sync_result = bus.emit( synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"}) return {"applied": True, "synced": synced}
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
@registry.register(GET_DNSMASQ_STATUS) @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"] entry["dns"] = body["dns"]
ranges.append(entry) ranges.append(entry)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end} 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" f"DHCP range for interface '{iface}' ({start}-{end}) not found"
) )
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end} 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: if hostname is not None:
leases[i]["hostname"] = hostname leases[i]["hostname"] = hostname
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname} return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip} entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
leases.append(entry) leases.append(entry)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname} 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: if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found") raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"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: if hostname is not None:
records[i]["hostname"] = hostname records[i]["hostname"] = hostname
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname} return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address} entry: dict[str, Any] = {"name": name, "address": address}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
records.append(entry) records.append(entry)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname} 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: if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found") raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"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 = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"]) cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"upstreams": cfg["dns"]["upstreams"]} 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 = _get_config()
cfg["dns"]["domain"] = domain if domain else None cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("dnsmasq", {"action": "domain_set"})
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"domain": cfg["dns"]["domain"]} return {"domain": cfg["dns"]["domain"]}
+105 -134
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from daemon.handlers.common import emit_and_refresh
from daemon.iface import ( from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE, DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE, DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -33,7 +34,7 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES, POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES, 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 import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import ( from lib.firewall import (
@@ -43,11 +44,11 @@ from lib.firewall import (
_parse_all_zones_output, _parse_all_zones_output,
_parse_zone_output, _parse_zone_output,
fw_change_summary, fw_change_summary,
validate_coverage,
) )
from lib.firewall import ( from lib.firewall import (
save_backup as _save_backup, save_backup as _save_backup,
) )
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg, indent=2) 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: def _reload() -> None:
"""Reload firewalld to apply permanent changes.""" """Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True) 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 - the config would strip both https and ssh from the default zone
(management lockout); (management lockout);
- a network-subsystem-managed interface would end up with no firewall - the config leaves a network-subsystem-managed interface with no
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded). firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so The config is the source of truth for zone interfaces — an absent
their current live interfaces count as coverage, as do the live ``interfaces`` key counts as empty — so coverage is computed from the
interfaces of zones that are live but absent from the config. 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 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}}.' f'to the zone\'s services, or pass {{"force": true}}.'
) )
# Coverage guard: after apply, every network-managed interface must # Coverage invariant: every network-managed interface must be
# belong to a zone or traffic (and DHCP) on that segment is dropped. # covered by a zone in the config (or declared unmanaged), or
live_active = _parse_active_zones( # traffic (and DHCP) on that segment is dropped. Pure config check
run(["firewall-cmd", "--get-active-zones"], sudo=True) # — the config is the source of truth, so no live-state comparison.
) uncovered = validate_coverage(cfg, network.get_config())
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]
if uncovered: if uncovered:
raise ConflictError( raise ConflictError(
"Refusing to apply: " "Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} " f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all " f"have no firewall zone coverage in the config and are not "
f"traffic (including DHCP) from those segments would be " f"declared unmanaged, so all traffic (including DHCP) from "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.' 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 # 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. # Step 3: Reconcile interfaces — same remove-then-add pattern.
# Absent "interfaces" key = hands off (keep the zone's live # The config is the source of truth: an absent "interfaces" key
# interfaces); an explicit empty list = intentional unassign-all. # counts as an empty list (unassign-all), matching the coverage
if "interfaces" in zone_cfg: # invariant and the pending diff.
current_ifaces: list[str] = [] current_ifaces: list[str] = []
with suppress(Exception): with suppress(Exception):
current_ifaces = _parse_zone_output( current_ifaces = _parse_zone_output(
zone_name, zone_name,
run( run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True ).get("interfaces", [])
), for iface in current_ifaces:
).get("interfaces", []) run(
for iface in current_ifaces: [
run( "firewall-cmd",
[ f"--zone={zone_name}",
"firewall-cmd", "--remove-interface=" + iface,
f"--zone={zone_name}", "--permanent",
"--remove-interface=" + iface, ],
"--permanent", sudo=True,
], check=False,
sudo=True, )
check=False, for iface in zone_cfg.get("interfaces", []):
) run(
for iface in zone_cfg.get("interfaces", []): [
run( "firewall-cmd",
[ f"--zone={zone_name}",
"firewall-cmd", "--add-interface=" + iface,
f"--zone={zone_name}", "--permanent",
"--add-interface=" + iface, ],
"--permanent", sudo=True,
], )
sudo=True,
)
# Step 4: Toggle masquerade if explicitly set (None means "don't change"). # Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables. # 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``. Dict with ``config_saved`` flag set to ``True``.
Raises: Raises:
ValueError: If body is empty, missing ``zones`` key, ValueError: If body is empty, missing ``zones`` key, ``zones`` is
or ``zones`` is not a dict. 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: if not body or "zones" not in body:
raise ValueError("'zones' key is required") raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict): if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a 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) _save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"])) logger.info("Firewall config saved (%d zones)", len(body["zones"]))
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "config_saved"})
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"config_saved": True} 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``. Dict with ``config_saved`` flag set to ``True``.
Raises: 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: if not body:
raise ValueError("Request body must be a JSON object") 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 from lib.common import deep_merge
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_check_coverage(merged)
_save_config(merged) _save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys())) logger.info("Firewall config patched: %s", sorted(body.keys()))
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "config_patched"})
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"config_saved": True} return {"config_saved": True}
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Raises: Raises:
ConflictError: If the config would strip both https and ssh from the ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without default zone, or would remove zone coverage from a
zone coverage, and ``force`` is not set. network-managed interface that is covered now, and ``force`` is
not set.
""" """
force = bool(_body and _body.get("force")) force = bool(_body and _body.get("force"))
result = _config_apply(force=force) result = _config_apply(force=force)
logger.info("Firewall config applied: %s", result.get("applied_zones", [])) logger.info("Firewall config applied: %s", result.get("applied_zones", []))
sync_result = bus.emit( synced = emit_and_refresh("firewall", {"action": "config_applied"})
SyncEvent("firewall", "config_saved", {"action": "config_applied"}) result["synced"] = synced
)
refresh_state(["firewall", *sync_result.affected_subsystems])
result["synced"] = sync_result.affected_subsystems
return result return result
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
) )
_reload() _reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target) logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
SyncEvent(
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"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) run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload() _reload()
logger.info("Zone '%s' deleted", zone) logger.info("Zone '%s' deleted", zone)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"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) _save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces) logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "interfaces": interfaces} 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) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services) logger.info("Zone '%s' services set to %s", zone, services)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "services": services} 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) cfg["zones"][zone]["rich_rules"].append(entry)
stamp_applied(cfg) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": rule_id, "rule": rule} 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) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": rule_id} 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) zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "masquerade": bool(enable)} 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) cfg["zones"][zone]["forward_ports"].append(entry)
stamp_applied(cfg) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto} 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) stamp_applied(cfg)
_save_config(cfg) _save_config(cfg)
sync_result = bus.emit( emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "port": int(port), "proto": proto} return {"zone": zone, "port": int(port), "proto": proto}
+8 -17
View File
@@ -10,6 +10,7 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import ( from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES, GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES, GET_NETWORK_INFER_ZONES,
@@ -20,7 +21,7 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD, POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET, 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.common import run, stamp_applied, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg from lib.dnsmasq import save_config as _save_dm_cfg
@@ -36,7 +37,6 @@ from lib.network import (
render_network_file, render_network_file,
save_config, save_config,
) )
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) 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() cfg_after = get_config()
stamp_applied(cfg_after) stamp_applied(cfg_after)
save_config(cfg_after) save_config(cfg_after)
sync_result = bus.emit( synced = emit_and_refresh(
SyncEvent( "networkd", {"action": "interface_saved", "interface": name}
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
)
) )
refresh_state(["networkd", *sync_result.affected_subsystems])
return { return {
"name": name, "name": name,
"applied": deployed, "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() cfg_after = get_config()
stamp_applied(cfg_after) stamp_applied(cfg_after)
save_config(cfg_after) save_config(cfg_after)
sync_result = bus.emit( synced = emit_and_refresh("networkd", {"action": "config_applied"})
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
logger.info( logger.info(
"Network config applied: %d interfaces, %d stale cleaned", "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), "applied": len(generated),
"files": [str(p) for p in generated], "files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned], "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) logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit( emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {"name": name, "value": value} return {"name": name, "value": value}
+15 -61
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import ( from daemon.iface import (
DELETE_WIREGUARD_CLASSES, DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN, DELETE_WIREGUARD_CLASSES_DOWN,
@@ -27,9 +28,8 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE, POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD, 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.common import deep_merge, run, stamp_applied, strip_apply_meta
from lib.sync import SyncEvent, bus
from lib.wireguard import ( from lib.wireguard import (
_class_interface_name, _class_interface_name,
_class_peers, _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", {}) body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body) _save_wireguard_config(body)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "config_saved"})
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"config_saved": True} 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() current = _get_wireguard_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_wireguard_config(merged) _save_wireguard_config(merged)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "config_patched"})
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"config_saved": True} return {"config_saved": True}
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_wireguard_config() cfg_after = _get_wireguard_config()
stamp_applied(cfg_after) stamp_applied(cfg_after)
_save_wireguard_config(cfg_after) _save_wireguard_config(cfg_after)
sync_result = bus.emit( synced = emit_and_refresh("wireguard", {"action": "config_applied"})
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return { return {
"applied": True, "applied": True,
"synced": sync_result.affected_subsystems, "synced": synced,
"interfaces": affected, "interfaces": affected,
} }
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
except Exception: except Exception:
pass pass
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "tunnel_down"})
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"down": True} 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) local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False) run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname) logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
SyncEvent(
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"up": True, "interface": ifname} 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) logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception: except Exception:
pass pass
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
SyncEvent(
"wireguard",
"config_saved",
{"action": "class_down", "class_key": class_key},
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"down": True, "interface": ifname} return {"down": True, "interface": ifname}
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16]) logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "initialized"})
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
safe = dict(cfg) safe = dict(cfg)
safe["interface"] = dict(safe["interface"]) 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) logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added" _peer_action = "peer_added"
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
peer_out = dict(peers[name]) peer_out = dict(peers[name])
peer_out.pop("private_key", None) peer_out.pop("private_key", None)
return peer_out return peer_out
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name] del peers[name]
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name) logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
SyncEvent(
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"name": name} return {"name": name}
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"public_key": "", "public_key": "",
} }
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "class_created"})
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key]) out = dict(classes[key])
out.pop("private_key", None) out.pop("private_key", None)
return out return out
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if field in body: if field in body:
class_cfg[field] = body[field] class_cfg[field] = body[field]
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "class_updated"})
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key]) out = dict(classes[key])
out.pop("private_key", None) out.pop("private_key", None)
return {"key": key, **out} return {"key": key, **out}
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
) )
del classes[key] del classes[key]
_save_wireguard_config(cfg) _save_wireguard_config(cfg)
sync_result = bus.emit( emit_and_refresh("wireguard", {"action": "class_deleted"})
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"key": key} return {"key": key}
+18 -17
View File
@@ -18,6 +18,7 @@ from typing import Any
from aiohttp import web from aiohttp import web
import daemon.collectors # noqa: F401 (registers state collectors)
from daemon.iface import PathLike from daemon.iface import PathLike
from lib.auth import blacklist_expired from lib.auth import blacklist_expired
from lib.state import _DEFAULT_POLL_INTERVALS from lib.state import _DEFAULT_POLL_INTERVALS
@@ -145,16 +146,20 @@ class Registry:
registry = 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). """Refresh the pre-computed state for the given subsystems (or all).
Args: Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed. 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) state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS targets = subsystems or state_store.SUBSYSTEMS
for name in targets: if bump:
state_store.bump(name) for name in targets:
state_store.bump(name)
try: try:
asyncio.get_running_loop() asyncio.get_running_loop()
except RuntimeError: except RuntimeError:
@@ -600,22 +605,11 @@ async def refresh_status(_request: web.Request) -> web.Response:
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
body = None body = None
subsystems = body.get("subsystems") if body else 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 # Deliberately no version bump — versions advance on structural poll
# diffs and on refresh_state() only. # diffs and on refresh_state() only.
async def _broadcast_all(): refresh_state(subsystems, bump=False)
await asyncio.gather( targets = subsystems or state_store.SUBSYSTEMS
*[broadcast_versions(name) for name in targets], snapshot = {name: state_store.get(name) for name in targets}
return_exceptions=True,
)
task = asyncio.create_task(_broadcast_all())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
return ok(snapshot) return ok(snapshot)
@@ -747,6 +741,13 @@ def main() -> None:
if reconciled: if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(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 # 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 # 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 # run as the WebUI user) would otherwise fail every daemon acme.sh
+29 -7
View File
@@ -433,7 +433,9 @@ POST /api/firewall/config
Replace the declarative config. Returns pending changes summary. 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`):** **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. 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`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
@@ -478,6 +484,10 @@ PATCH /api/firewall/config
Deep-merge the provided fields into the existing config. Returns pending changes summary. 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`):** **Response (`data`):**
| Field | Type | Description | | 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 `force` is forwarded to the firewall apply only — it overrides the
management-lockout and interface-coverage guards. Other subsystems management-lockout guard and the interface-coverage invariant. Other
ignore it. subsystems ignore it.
**Response (`data`):** **Response (`data`):**
@@ -1996,10 +2006,14 @@ ignore it.
The endpoint returns `200` even when some subsystems failed — per-subsystem The endpoint returns `200` even when some subsystems failed — per-subsystem
failures are reported in `errors`, so clients must check `errors` (not just failures are reported in `errors`, so clients must check `errors` (not just
the HTTP status) before reporting success. Without `force`, the firewall the HTTP status) before reporting success. Without `force`, the firewall
apply refuses if an interface would be left without zone coverage (the apply is refused when a network-managed interface has no zone coverage in
coverage guard) or both https/ssh would be stripped from the default zone the config and is not `unmanaged` (the interface-coverage invariant) or when
(lockout guard); the `ConflictError` surfaces in `errors` under the config would strip both https/ssh from the default zone (lockout guard);
`"Firewall"` while the other subsystems proceed. 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 — reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written. 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. **Request Body:** none.
**Response (`data`):** **Response (`data`):**
+17 -4
View File
@@ -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 ### Zone Fields
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`. 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 | | 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: `[]`. | | `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). | | `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`. | | `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 ### 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/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift. Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed. **Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
**Interface-coverage guard.** `POST /firewall/config/apply` also refuses (before any mutation) if applying would leave a network-subsystem-managed interface in **no** firewall zone — traffic (and DHCP) on that segment would be dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Zones whose config omits the `interfaces` key are left hands-off, so their current live interfaces count as coverage, as do the live interfaces of zones that are live but absent from the config. Send `"force": true` to override. The condition is always surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`) and as an advisory in `GET /api/status/pending`. **Interface-coverage invariant.** Every network-subsystem-managed interface must be covered by a zone in the firewall config — otherwise all traffic (and DHCP) from that segment is dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Because the config is the source of truth for zone interfaces (an omitted `interfaces` key counts as empty), coverage is computed from the config **alone** via `validate_coverage()` — there is no live-state fallback and no hands-off zones. Interfaces listed in the top-level `unmanaged` key are exempt. The invariant is enforced at two points:
- **Save time**`POST /firewall/config` and `PATCH /firewall/config` reject a config that leaves a managed interface uncovered with `400 Bad Request`, before anything is written.
- **Apply time**`POST /firewall/config/apply` re-checks the (possibly stale) saved config against the current network config and raises `409 Conflict` before any mutation. A conflict here means the network config changed after the firewall config was saved (e.g. a new interface no zone covers).
Send `"force": true` in the request body to override the apply-time check (the UI offers this via the Apply dialog). Live drift — an interface that is covered by the config but not in any **live** zone — is advisory only: it is surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`), the Zones-page banner, and an advisory in `GET /api/status/pending`, and is never blocked by the invariant.
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel. **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.
+15 -8
View File
@@ -24,10 +24,14 @@ return annotation references them.
differs from the recorded hash; `status.pending_diff` lists the field differs from the recorded hash; `status.pending_diff` lists the field
changes since that snapshot. All apply operations (including firewall changes since that snapshot. All apply operations (including firewall
`config_apply`) re-stamp the baseline. These bookkeeping keys are `config_apply`) re-stamp the baseline. These bookkeeping keys are
internal and stripped from every state/API config payload. Canceling internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset. (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 ## State shape summary
@@ -62,10 +66,13 @@ Top-level `FirewallState`:
// firewalld service XML definitions // firewalld service XML definitions
// (lib/firewall.py get_service_descriptions, // (lib/firewall.py get_service_descriptions,
// cached per process) // cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any live zone — // lo/wg*) not in any LIVE zone — a
// advisory coverage warning; empty = // live-drift advisory (config may still
// fully covered; NOT counted in pending // 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, zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports", // may carry "sources", "ports",
// "protocols", "forward-ports", "ics", // "protocols", "forward-ports", "ics",
+5 -2
View File
@@ -95,11 +95,14 @@ def _run_acme(args: list[str]) -> str:
acme_home_env, acme_home_env,
"--config-home", "--config-home",
acme_home_env, acme_home_env,
*args,
# Append the full transcript to $ACME_HOME/acme.sh.log so manual # Append the full transcript to $ACME_HOME/acme.sh.log so manual
# runs (whose stdout is captured below) leave a persistent record # 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", "--log",
*args,
] ]
try: try:
+40
View File
@@ -0,0 +1,40 @@
"""Daemon-startup filesystem bootstrap.
Runs once at daemon startup, after the system-config import and before the
first state collection. Creates the runtime directories subsystems
read/write and persists the one-shot nginx legacy-format migration.
Config *files* are deliberately NOT created here: ``get_config`` reads are
pure and return in-memory defaults, and the system-config import must see
absent files in order to adopt live system state on first start. Files are
materialized on the first ``save_config`` (or by the import itself).
"""
from lib import dnsmasq, firewall, network, nginx, wireguard
from lib.common import ensure_dirs
__all__ = ["bootstrap"]
def bootstrap() -> None:
"""Create runtime directories and persist the one-shot nginx migration.
Idempotent existing directories are left untouched and the nginx
migration only rewrites the on-disk file when it actually changes.
"""
ensure_dirs(
dnsmasq.CONFIG_DIR,
dnsmasq.DATA_DIR,
dnsmasq.FRAGMENTS_DIR,
firewall.CONFIG_DIR,
firewall.DATA_DIR,
network.CONFIG_DIR,
network.DATA_DIR,
nginx.CONFIG_DIR,
nginx.SITES_DIR,
wireguard.CONFIG_PATH.parent,
)
# One-shot legacy-format migration for the nginx config (see
# ``lib.nginx.get_config``). Runs here, at startup, so read paths stay
# side-effect free.
nginx.migrate_config_file()
+19
View File
@@ -120,6 +120,24 @@ def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> Non
out.append({"path": path, "action": "changed", "old": old, "new": new}) 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: def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name. """Validate a Linux network interface name.
@@ -301,6 +319,7 @@ __all__ = [
"_APPLY_HASH_KEY", "_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY", "_LAST_APPLIED_CONFIG_KEY",
"_hash_password", "_hash_password",
"compute_pending",
"config_hash", "config_hash",
"deep_diff", "deep_diff",
"deep_merge", "deep_merge",
+11 -2
View File
@@ -37,8 +37,12 @@ DEFAULT_CFG: dict[str, Any] = {
def get_config() -> dict[str, Any]: def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file.""" """Load current dnsmasq config from JSON state file.
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
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) raw = load_json(CONFIG_PATH)
if not raw: if not raw:
return deepcopy(DEFAULT_CFG) return deepcopy(DEFAULT_CFG)
@@ -73,6 +77,11 @@ def set_domain(domain: str | None) -> None:
__all__ = [ __all__ = [
"CONFIG_DIR",
"CONFIG_PATH",
"DATA_DIR",
"DEFAULT_CFG",
"FRAGMENTS_DIR",
"get_config", "get_config",
"save_config", "save_config",
"set_domain", "set_domain",
+72 -20
View File
@@ -7,6 +7,7 @@ All privileged commands are handled by daemon/handlers/firewall.py.
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -327,9 +328,16 @@ def _ensure_config_file() -> None:
def get_config() -> dict[str, Any]: def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``.""" """Return the declarative config from ``config/firewall/config.json``.
_ensure_config_file()
return load_json(CONFIG_FILE) 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: 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 Pure function no subprocess calls. Caller is responsible for providing
live state (typically from the daemon). live state (typically from the daemon).
The interfaces diff is only reported for zones whose config explicitly The config is the source of truth for zone interfaces: an absent
carries an ``interfaces`` key; zones with the key absent are hands-off ``interfaces`` key counts as an empty list, so every config zone is
(apply keeps their live interfaces), so diffing them would advertise diffed on interfaces. Likewise the target diff is only reported when the
changes that never happen. Likewise the target diff is only reported when config carries an explicit target that normalizes to something other
the config carries an explicit target that normalizes to something other
than ``default`` an absent key or a ``default``-normalizing value is than ``default`` an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones. 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(): for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {}) live_zone = live_zones.get(zone_name, {})
if "interfaces" in zone_cfg: # The config is the source of truth for zone interfaces: an absent
cfg_ifaces = set(zone_cfg.get("interfaces", [])) # key counts as an empty list, so every config zone is diffed.
live_ifaces = set(live_zone.get("interfaces", [])) cfg_ifaces = set(zone_cfg.get("interfaces", []))
if cfg_ifaces != live_ifaces: live_ifaces = set(live_zone.get("interfaces", []))
changes.append( if cfg_ifaces != live_ifaces:
{ changes.append(
"zone": zone_name, {
"type": "interfaces", "zone": zone_name,
"config": sorted(cfg_ifaces), "type": "interfaces",
"live": sorted(live_ifaces), "config": sorted(cfg_ifaces),
} "live": sorted(live_ifaces),
) }
)
cfg_services = set(zone_cfg.get("services", [])) cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.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]: def config_pending(state: dict[str, Any]) -> dict[str, Any]:
"""Compare declarative config against firewalld live state, return diff. """Compare declarative config against firewalld live state, return diff.
@@ -554,4 +605,5 @@ __all__ = [
"load_backup", "load_backup",
"save_backup", "save_backup",
"save_config", "save_config",
"validate_coverage",
] ]
+8 -4
View File
@@ -8,6 +8,7 @@ import contextlib
import ipaddress import ipaddress
import json import json
import logging import logging
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -78,13 +79,16 @@ __all__ = [
def get_config() -> dict[str, Any]: def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json. """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: Returns:
Dict with ``interfaces`` mapping interface names to config entries. Dict with ``interfaces`` mapping interface names to config entries.
""" """
if not CONFIG_FILE.exists(): raw = load_json(CONFIG_FILE)
CONFIG_DIR.mkdir(parents=True, exist_ok=True) if not raw:
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2) return deepcopy(DEFAULT_CONFIG)
return load_json(CONFIG_FILE) return raw
def save_config(cfg: dict[str, Any]) -> None: def save_config(cfg: dict[str, Any]) -> None:
+24 -9
View File
@@ -169,31 +169,45 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
def get_config() -> dict[str, Any]: 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 Never writes or creates directories. Returns the in-memory default when
legacy formats, then returns the config dict. 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: Returns:
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys. The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
""" """
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE) raw = load_json(CONFIG_FILE)
if not raw: if not raw:
raw = deepcopy(DEFAULT_CONFIG) raw = deepcopy(DEFAULT_CONFIG)
save_config(raw)
return raw
if "ssl" not in raw: if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL) raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw: if "backends" not in raw:
raw["backends"] = {} 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) pre = deepcopy(raw)
raw = _migrate_config(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: if raw != pre:
save_config(raw) save_config(raw)
return raw return True
return False
def save_config(cfg: dict[str, Any]) -> None: def save_config(cfg: dict[str, Any]) -> None:
@@ -616,6 +630,7 @@ __all__ = [
"get_config", "get_config",
"get_domains", "get_domains",
"get_management_domains", "get_management_domains",
"migrate_config_file",
"remove_domain", "remove_domain",
"save_config", "save_config",
"test_config", "test_config",
+12 -997
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -370,7 +370,7 @@ def status() -> dict[str, Any]:
if res.returncode != 0: if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []} result["classes"][class_key] = {"up": False, "peers": []}
continue 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 result["classes"][class_key] = class_status
if class_status["up"]: if class_status["up"]:
result["up"] = True result["up"] = True
@@ -382,7 +382,7 @@ def status() -> dict[str, Any]:
ifname = cfg["interface"].get("name", "wg0") ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False) res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0: 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["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {}) result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", []) result["peers"] = parsed.get("peers", [])
@@ -392,8 +392,12 @@ def status() -> dict[str, Any]:
return result return result
def _parse_wg_show_output(raw: str) -> dict[str, Any]: def parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict.""" """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] = { result: dict[str, Any] = {
"up": False, "up": False,
"interface": {}, "interface": {},
@@ -742,6 +746,7 @@ __all__ = [
"get_peer_status", "get_peer_status",
"get_peers", "get_peers",
"initialize", "initialize",
"parse_wg_show_output",
"remove_peer", "remove_peer",
"save_config", "save_config",
"set_listen_port", "set_listen_port",
+14
View File
@@ -58,6 +58,20 @@ class TestRunAcme:
assert cmd[0] == "/usr/local/bin/acme.sh" assert cmd[0] == "/usr/local/bin/acme.sh"
assert "sudo" not in cmd 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: class TestParseListOutput:
def test_parses_single_entry(self): def test_parses_single_entry(self):
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
import pytest
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
"""Point every bootstrap-referenced path into a throwaway tree."""
cfg = tmp_path / "config"
data = tmp_path / "data"
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
return tmp_path
def test_creates_runtime_dirs(sandbox):
bootstrap.bootstrap()
assert dnsmasq.FRAGMENTS_DIR.is_dir()
assert firewall.DATA_DIR.is_dir()
assert network.DATA_DIR.is_dir()
assert nginx.SITES_DIR.is_dir()
assert wireguard.CONFIG_PATH.parent.is_dir()
def test_does_not_create_config_files(sandbox):
# Config files are left for system-import (first start) or the first
# save_config — bootstrap must not pre-empt either.
bootstrap.bootstrap()
assert not nginx.CONFIG_FILE.exists()
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
assert not (network.CONFIG_DIR / "config.json").exists()
assert not (firewall.CONFIG_DIR / "config.json").exists()
assert not wireguard.CONFIG_PATH.exists()
def test_persists_nginx_migration(sandbox):
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
bootstrap.bootstrap()
on_disk = nginx.get_config()
assert on_disk["backends"]["webui"]["_migrated"] is True
raw = nginx.CONFIG_FILE.read_text()
assert '"_migrated": true' in raw or '"_migrated":True' in raw
def test_idempotent(sandbox):
nginx.save_config({"domains": {}})
bootstrap.bootstrap()
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
bootstrap.bootstrap()
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
+51
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from lib.common import ( from lib.common import (
_APPLY_HASH_KEY, _APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY, _LAST_APPLIED_CONFIG_KEY,
compute_pending,
config_hash, config_hash,
deep_diff, deep_diff,
load_json, load_json,
@@ -78,6 +79,56 @@ class TestDeepDiff:
assert not any(p.startswith("z.ranges[0].n") for p in paths) 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: class TestDashboardFallback:
def test_hash_subsystem_unchanged_generic(self): def test_hash_subsystem_unchanged_generic(self):
# Guards that a pending status without a snapshot still yields a # Guards that a pending status without a snapshot still yields a
+227 -91
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest import pytest
from daemon.handlers import common as daemoncommon
from daemon.handlers import firewall as daemonfirewall from daemon.handlers import firewall as daemonfirewall
from daemon.server import ConflictError, NotFoundError from daemon.server import ConflictError, NotFoundError
from lib import firewall from lib import firewall
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
class TestComputePendingChangesAbsentInterfaces: class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply, """The config is the source of truth for zone interfaces: an absent
so their interfaces diff must not be reported; other field drift is.""" '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"]}}} cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes( result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC} cfg, {"public": _PENDING_LIVE_PUBLIC}
) )
types = {c["type"] for c in result["pending"]} types = {c["type"] for c in result["pending"]}
assert "services" in types 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): def test_absent_key_zone_in_sync_live_reports_nothing(self):
# Config in sync on everything except a missing interfaces key: the # Config lacks the interfaces key and the live zone has no
# zone's live interfaces are intentionally left alone by apply. # interfaces either — absent key equals the empty live set.
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
cfg = {"zones": {"public": {"services": ["http"]}}} cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes( result = firewall._compute_pending_changes(cfg, {"public": live})
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
assert result["pending"] == [] assert result["pending"] == []
assert result["needs_apply"] is False 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: class TestGetZoneInfo:
def test_parses_zone_info(self): def test_parses_zone_info(self):
result = firewall._parse_zone_output( result = firewall._parse_zone_output(
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
"daemon.handlers.firewall._get_state", "daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}}, return_value={"zones": {"public": {}}},
), ),
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
patch( patch(
"daemon.handlers.firewall._get_config", "daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}}, return_value={"zones": {"public": {}}},
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"), patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config") as mock_save, patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services( result = daemonfirewall.set_zone_services(
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"), patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"), patch.object(daemonfirewall, "_save_config"),
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services( result = daemonfirewall.set_zone_services(
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
"daemon.handlers.firewall._get_state", "daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}}, return_value={"zones": {"public": {}}},
), ),
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
patch( patch(
"daemon.handlers.firewall._get_config", "daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}}, return_value={"zones": {"public": {}}},
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface # Coverage invariant: the config must cover every network-managed
# in no zone (clients lose connectivity/DHCP) unless forced. # 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, ) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"), patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup), patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
patch( patch(
"daemon.handlers.firewall._get_config", "daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg), return_value=deepcopy(cfg),
@@ -860,21 +909,88 @@ def _apply_with(
return result, mock_run return result, mock_run
class TestDaemonInterfaceCoverageGuard: class TestDaemonCoverageInvariant:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self): def test_conflict_when_network_iface_uncovered(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}} 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") result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"] assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front. cmds = [c.args[0] for c in mock_run.call_args_list]
assert mock_run.call_args_list[0].args[0] == [ # Live eth0 is removed, config eth1 added exactly once (permanent).
assert [
"firewall-cmd", "firewall-cmd",
"--get-active-zones", "--zone=public",
] "--remove-interface=eth0",
# Hands off: no interface mutation commands for the absent-key zone. "--permanent",
for c in mock_run.call_args_list: ] in cmds
for arg in c.args[0]: assert (
assert not arg.startswith("--remove-interface=") cmds.count(
assert not arg.startswith("--add-interface=") ["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): def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}} cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds 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 ( with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}), patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True), patch("daemon.handlers.firewall._save_config") as mock_save,
patch( pytest.raises(ValueError) as exc,
"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,
): ):
daemonfirewall._config_apply() daemonfirewall.save_config_handler(None, body)
assert "eth0" in str(exc.value) 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): def test_save_allows_unmanaged(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}} body = {
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True) "zones": {"public": {"interfaces": ["eth1"], "services": []}},
assert result["applied_zones"] == ["public"] "unmanaged": ["eth0"],
}
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": []}}}
with ( with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}), patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True), patch("daemon.handlers.firewall._save_config") as mock_save,
patch( patch.object(daemoncommon, "bus") as mock_bus,
"daemon.handlers.firewall.run", patch("daemon.handlers.common.refresh_state"),
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),
): ):
daemonfirewall._config_apply() mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
mock_backup.assert_not_called() result = daemonfirewall.save_config_handler(None, body)
# Only the guard's live-zone read ran — no mutation commands at all. assert result == {"config_saved": True}
assert [c.args[0] for c in mock_run.call_args_list] == [ mock_save.assert_called_once()
["firewall-cmd", "--get-active-zones"]
] 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 ( with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run, patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"), patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body) result = daemonfirewall.create_zone(None, body)
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
"daemon.handlers.firewall._save_backup", "daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json", return_value="/tmp/rules.json",
) as mock_backup, ) 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._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"), patch("daemon.handlers.firewall._save_config"),
): ):
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
"daemon.handlers.firewall._get_state", "daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}}, return_value={"zones": {"public": {}}},
), ),
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
patch( patch(
"daemon.handlers.firewall._get_config", "daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG), 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): def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with ( with (
patch.object(daemonfirewall, "_save_config") as mock_save, patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_interfaces( daemonfirewall.set_zone_interfaces(
@@ -1138,8 +1274,8 @@ class TestDaemonMutatorBaselineStamp:
daemonfirewall, "_parse_zone_output", return_value={"services": []} daemonfirewall, "_parse_zone_output", return_value={"services": []}
), ),
patch.object(daemonfirewall, "_save_config") as mock_save, patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_services( daemonfirewall.set_zone_services(
@@ -1162,8 +1298,8 @@ class TestDaemonMutatorBaselineStamp:
): ):
with ( with (
patch.object(daemonfirewall, "_save_config") as mock_save, patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True}) daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
@@ -1182,8 +1318,8 @@ class TestDaemonMutatorBaselineStamp:
would manufacture spurious service diffs on the next poll.""" would manufacture spurious service diffs on the next poll."""
with ( with (
patch.object(daemonfirewall, "_save_config") as mock_save, patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus, patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"), patch("daemon.handlers.common.refresh_state"),
): ):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False}) daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
@@ -1208,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
"daemon.handlers.firewall._config_apply", "daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"}, 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): def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[]) 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) daemonfirewall.config_apply(None, None)
mock_apply.assert_called_once_with(force=False) mock_apply.assert_called_once_with(force=False)
mock_apply.reset_mock() mock_apply.reset_mock()
+3 -2
View File
@@ -29,10 +29,11 @@ class TestGetConfig:
assert isinstance(cfg, dict) assert isinstance(cfg, dict)
assert "interfaces" in cfg 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() cfg = _net.get_config()
assert _net.CONFIG_FILE.exists()
assert cfg["interfaces"] == {} assert cfg["interfaces"] == {}
assert not _net.CONFIG_FILE.exists()
class TestSaveConfig: class TestSaveConfig:
+12 -10
View File
@@ -279,18 +279,18 @@ class TestStateParserDedup:
"""Verify lib/state.py uses lib.network.parse_networkctl_status().""" """Verify lib/state.py uses lib.network.parse_networkctl_status()."""
def test_state_uses_network_parser(self): def test_state_uses_network_parser(self):
"""The networkd collector in state.py should import from lib.network.""" """The networkd collector should import from lib.network."""
import lib.state as _state import daemon.collectors.networkd as _collector
source = Path(_state.__file__).read_text() source = Path(_collector.__file__).read_text()
assert "from lib.network import parse_networkctl_status" in source assert "from lib.network import" in source
assert "parse_networkctl_status" in source assert "parse_networkctl_status" in source
def test_networkd_collector_returns_correct_format(self): def test_networkd_collector_returns_correct_format(self):
"""_collect_networkd should return interfaces dict + timestamp.""" """_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( mock_run.return_value = json.dumps(
{ {
"Interfaces": [ "Interfaces": [
@@ -320,7 +320,7 @@ class TestStateParserDedup:
] ]
} }
) )
result = _state._collect_networkd() result = _collector._collect_networkd()
assert "interfaces" in result assert "interfaces" in result
assert "timestamp" in result assert "timestamp" in result
@@ -329,10 +329,12 @@ class TestStateParserDedup:
def test_networkd_collector_handles_failure(self): def test_networkd_collector_handles_failure(self):
"""_collect_networkd returns empty interfaces on error.""" """_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")): with patch(
result = _state._collect_networkd() "daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
):
result = _collector._collect_networkd()
assert result["interfaces"] == {} assert result["interfaces"] == {}
assert "timestamp" in result assert "timestamp" in result
+17 -3
View File
@@ -70,14 +70,28 @@ class TestGetConfig:
# No churn: reading a current-format config leaves the file alone. # No churn: reading a current-format config leaves the file alone.
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_read_saves_when_migration_applied(self, temp_data_dir): def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
"""get_config() persists the file when migration actually changes it.""" """get_config() is pure: migration is applied in memory, file untouched."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}}) nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config() 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 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 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: class TestSaveConfig:
+25 -15
View File
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
import json import json
from unittest.mock import Mock, patch 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 from lib import schema
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
class TestCollectorShapesMatchSchema: class TestCollectorShapesMatchSchema:
def test_firewall_state(self): def test_firewall_state(self):
with ( with (
patch.object(lib.state, "run") as mock_run, patch.object(daemon.collectors.firewall, "run") as mock_run,
patch.object( patch.object(
lib.state, daemon.collectors.firewall,
"_network_get_config", "_network_get_config",
return_value={ return_value={
"interfaces": { "interfaces": {
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
return "" return ""
mock_run.side_effect = run_side 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) assert not _missing(schema.FirewallState.__required_keys__, result)
for iface in result["interfaces"]: for iface in result["interfaces"]:
@@ -74,38 +80,40 @@ class TestCollectorShapesMatchSchema:
assert result["uncovered_interfaces"] == ["eth1"] assert result["uncovered_interfaces"] == ["eth1"]
def test_dnsmasq_state(self): 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) 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) assert not _missing(schema.DnsmasqState.__required_keys__, result)
for k in schema.DnsmasqStatus.__required_keys__: for k in schema.DnsmasqStatus.__required_keys__:
assert k in result["status"], f"DnsmasqStatus missing {k}" assert k in result["status"], f"DnsmasqStatus missing {k}"
def test_nginx_state(self): 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 not _missing(schema.NginxState.__required_keys__, result)
assert "pending_changes" in result["status"] assert "pending_changes" in result["status"]
def test_acme_state(self): def test_acme_state(self):
with ( 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("lib.acme.list_certs", return_value=[]),
patch.object( patch.object(
lib.state, daemon.collectors.acme,
"_parse_account_conf", "_parse_account_conf",
return_value={"registered": False, "email": "", "ca": ""}, 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 not _missing(schema.AcmeState.__required_keys__, result)
assert result["status"]["error"] is None assert result["status"]["error"] is None
def test_wireguard_state(self): 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) 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) assert not _missing(schema.WgState.__required_keys__, result)
for k in schema.WgStatus.__required_keys__: for k in schema.WgStatus.__required_keys__:
@@ -135,8 +143,10 @@ class TestCollectorShapesMatchSchema:
} }
] ]
} }
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)): with patch.object(
result = lib.state._collect_networkd() daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
):
result = daemon.collectors.networkd._collect_networkd()
assert not _missing(schema.NetworkdState.__required_keys__, result) assert not _missing(schema.NetworkdState.__required_keys__, result)
assert "eth0" in result["interfaces"] assert "eth0" in result["interfaces"]
@@ -148,7 +158,7 @@ class TestCollectorShapesMatchSchema:
def test_system_state(self): def test_system_state(self):
"""Reads /proc and /sys directly — no mocking needed on Linux.""" """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) assert not _missing(schema.SystemState.__required_keys__, result)
for k in schema.CpuLoad.__required_keys__: for k in schema.CpuLoad.__required_keys__:
assert k in result["load"], f"CpuLoad missing {k}" assert k in result["load"], f"CpuLoad missing {k}"
+31 -19
View File
@@ -3,7 +3,9 @@
import json import json
from unittest.mock import patch 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 from lib.state import State, state
@@ -51,9 +53,9 @@ class TestState:
class TestCollectAll: class TestCollectAll:
@patch("lib.state.run") @patch("daemon.collectors.firewall.run")
def test_collect_firewall_returns_dict(self, mock_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): def run_side(args, **kwargs):
if "--get-active-zones" in args: if "--get-active-zones" in args:
@@ -88,10 +90,10 @@ class TestCollectAll:
assert "interfaces" in result assert "interfaces" in result
assert "timestamp" 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): def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected.""" """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): def run_side(args, **kwargs):
if "--get-active-zones" in args: if "--get-active-zones" in args:
@@ -144,10 +146,10 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs" assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"] assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions") @patch("daemon.collectors.firewall.get_service_descriptions")
@patch("lib.state.run") @patch("daemon.collectors.firewall.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc): 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): def run_side(args, **kwargs):
if "--get-active-zones" in args: if "--get-active-zones" in args:
@@ -168,11 +170,11 @@ class TestCollectAll:
mock_desc.assert_called_once_with() mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs 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): def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock 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) mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq() result = _collect_dnsmasq()
@@ -181,12 +183,12 @@ class TestCollectAll:
assert "config" in result assert "config" in result
assert "leases" 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): def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock 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.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True) (tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = { applied = {
@@ -226,7 +228,9 @@ class TestCollectAll:
_APPLY_HASH_KEY: "stale-hash", _APPLY_HASH_KEY: "stale-hash",
} }
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg)) (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 # service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0) 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).""" """A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
def test_list_failure_yields_empty_certs_and_error(self): def test_list_failure_yields_empty_certs_and_error(self):
from lib.state import _collect_acme from daemon.collectors.acme import _collect_acme
with ( 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( patch(
"lib.acme.list_certs", "lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"), side_effect=RuntimeError("acme.sh failed with exit code 2"),
), ),
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT), patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
): ):
result = _collect_acme() result = _collect_acme()
@@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal:
assert "exit code 2" in result["status"]["error"] assert "exit code 2" in result["status"]["error"]
def test_success_reports_no_error(self): def test_success_reports_no_error(self):
from lib.state import _collect_acme from daemon.collectors.acme import _collect_acme
with ( 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("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() result = _collect_acme()
+38 -2
View File
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
" listening port: 51820\n" " listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\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["up"] is True
assert result["interface"]["public_key"] == "IFACE-PUB" assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820 assert result["interface"]["listen_port"] == 51820
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"] assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self): def test_empty_output(self):
result = wireguard._parse_wg_show_output("") result = wireguard.parse_wg_show_output("")
assert result["up"] is False assert result["up"] is False
assert result["peers"] == [] 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: class TestAccessClasses:
def test_default_config_has_access_classes(self): def test_default_config_has_access_classes(self):
+75 -239
View File
@@ -3,11 +3,11 @@
Exposed at /api/certs/* and delegates to vacuum-walld. 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 ( from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE, DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE, DELETE_ACME_REMOVE,
@@ -22,269 +22,105 @@ from daemon.iface import (
POST_ACME_RENEW, POST_ACME_RENEW,
POST_ACME_VALIDATE, 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 = Blueprint("certs", __name__)
@bp.route("/list", methods=["GET"]) def _validate_body(request: Any, _va: Any) -> dict[str, Any]:
def list_certs_bp(): domain = ((request.get_json(silent=True) or {}).get("domain") or "").strip()
"""GET /api/certs/list — list all managed ACME certificates. if not domain:
raise ValueError("'domain' is required")
Returns: return {"domain": domain}
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)
@bp.route("/<domain>", methods=["GET"]) def _issue_body(request: Any, _va: Any) -> dict[str, Any]:
def cert_details(domain: str):
"""GET /api/certs/<domain> — get details for a specific certificate.
Args:
domain: Domain name to look up.
Returns:
Response containing certificate info or an error message.
"""
try:
return _ok(get(GET_ACME_INFO, {"domain": domain}))
except NotFound as exc:
logger.info("Cert for '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/validate", methods=["POST"])
def validate():
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
Expects JSON body with ``{``domain``}``.
Returns:
Response containing validation results or an error message.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
domain = (body.get("domain") or "").strip() domain = (body.get("domain") or "").strip()
if not domain: if not domain:
return _error("'domain' is required", 400) raise ValueError("'domain' is required")
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)
email = (body.get("email") or "").strip() or None email = (body.get("email") or "").strip() or None
webroot = body.get("webroot") return {"domain": domain, "webroot": body.get("webroot"), "email": email}
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)
@bp.route("/issue/<request_id>", methods=["GET"]) def _email_body(request: Any, _va: Any) -> dict[str, Any]:
def issue_status(request_id: str): email = ((request.get_json(silent=True) or {}).get("email") or "").strip()
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request. if not email:
raise ValueError("'email' is required")
Args: return {"email": email}
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)
@bp.route("/<domain>/renew", methods=["POST"]) def _register_body(request: Any, _va: Any) -> dict[str, Any]:
def renew_bp(domain: str): body = request.get_json(silent=True) or {}
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal. email = (body.get("email") or "").strip()
if not email:
Returns: raise ValueError("'email' is required")
Response containing a renewal request ID (poll it at return {"email": email, "server": (body.get("server") or "").strip()}
``/api/certs/renew/<request_id>``) or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
result = post(POST_ACME_RENEW, {"domain": domain})
logger.info(
"Certificate renewal started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to renew cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/renew/<request_id>", methods=["GET"]) def _email_echo(_data: Any, _va: Any, sent: Any) -> Any:
def renew_status(request_id: str): return {"email": sent["email"]}
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
Args:
request_id: Renewal request identifier returned by renew_bp.
Returns:
Response containing renewal status or an error message.
"""
try:
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Renewal request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
@bp.route("/<domain>", methods=["DELETE"]) @daemon_route(GET_ACME_LIST, bp)
def remove_bp(domain: str): def list_certs_bp():
"""DELETE /api/certs/<domain> — remove a certificate from ACME management. """GET /api/certs/list — List all managed ACME certificates."""
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)
@bp.route("/email", methods=["POST"]) @daemon_route(GET_ACME_INFO, bp, rule="/<domain>")
def cert_details():
"""GET /api/certs/<domain> — Get details for a specific certificate."""
@daemon_route(POST_ACME_VALIDATE, bp, body=_validate_body)
def validate():
"""POST /api/certs/validate — Run pre-flight checks for issuance."""
@daemon_route(POST_ACME_ISSUE, bp, rule="/issue/start", body=_issue_body)
def issue_start():
"""POST /api/certs/issue/start — Create a new certificate issuance request."""
@daemon_route(
GET_ACME_ISSUE_STATUS, bp, rule="/issue/<request_id>", params={"id": "request_id"}
)
def issue_status():
"""GET /api/certs/issue/<request_id> — Poll status of an issuance request."""
@daemon_route(POST_ACME_RENEW, bp, rule="/<domain>/renew")
def renew_bp():
"""POST /api/certs/<domain>/renew — Start an (async) certificate renewal."""
@daemon_route(
GET_ACME_RENEW_STATUS, bp, rule="/renew/<request_id>", params={"id": "request_id"}
)
def renew_status():
"""GET /api/certs/renew/<request_id> — Poll status of a certificate renewal."""
@daemon_route(DELETE_ACME_REMOVE, bp, rule="/<domain>", transform=void_transform)
def remove_bp():
"""DELETE /api/certs/<domain> — Remove a certificate from ACME management."""
@daemon_route(POST_ACME_EMAIL, bp, body=_email_body, transform=_email_echo)
def set_email_bp(): def set_email_bp():
"""POST /api/certs/email — set the ACME account email address. """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)
@bp.route("/account", methods=["GET"]) @daemon_route(GET_ACME_ACCOUNT, bp)
def account(): def account():
"""GET /api/certs/account — return ACME account information. """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)
@bp.route("/account/register", methods=["POST"]) @daemon_route(POST_ACME_ACCOUNT_REGISTER, bp, body=_register_body)
def register_account(): def register_account():
"""POST /api/certs/account/register — register a new ACME 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)
@bp.route("/account", methods=["DELETE"]) @daemon_route(DELETE_ACME_ACCOUNT_DEACTIVATE, bp, rule="/account", body=NO_BODY)
def deactivate_account(): def deactivate_account():
"""DELETE /api/certs/account — deactivate the ACME 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)
+185 -6
View File
@@ -1,14 +1,36 @@
"""Shared API response helpers. """Shared API response helpers + daemon-proxy route factory.
Used by all API blueprints to produce consistent JSON responses Used by all API blueprints to produce consistent JSON responses per the
per the API response contract: ``{"ok": true, "data": <value>}`` / API response contract (``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``. ``{"ok": false, "error": "msg"}``) and to collapse the repetitive
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
single declarative ``daemon_route`` decorator.
The factory dispatches to the ``daemon.client`` verb imported into the
blueprint's own module namespace (resolved via ``sys.modules`` at request
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
""" """
from flask import jsonify from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from flask import Blueprint, jsonify, request
from daemon.client import BadRequest, Conflict, NotFound
from daemon.iface import Endpoint
logger = logging.getLogger(__name__)
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
NO_BODY = object()
Verb = Callable[..., Any]
def _ok(data=None): def _ok(data: Any = None):
"""Return a success JSON response.""" """Return a success JSON response."""
return jsonify({"ok": True, "data": data}) return jsonify({"ok": True, "data": data})
@@ -16,3 +38,160 @@ def _ok(data=None):
def _error(msg: str, code: int = 400): def _error(msg: str, code: int = 400):
"""Return an error JSON response with the given HTTP status code.""" """Return an error JSON response with the given HTTP status code."""
return jsonify({"ok": False, "error": msg}), code return jsonify({"ok": False, "error": msg}), code
def _derive_rule(path: str) -> str:
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
daemon endpoint path by dropping the leading subsystem segment.
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
``/issue/status``.
"""
parts = path.lstrip("/").split("/")
if len(parts) <= 1:
return "/"
return "/" + "/".join(parts[1:])
def _map_view_args(
view_args: dict[str, Any], params: dict[str, str] | None
) -> dict[str, Any]:
"""Map Flask view args onto daemon body keys.
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
not listed maps to itself (identity), so path params are always
forwarded and only renamed where the daemon expects a different key.
"""
result = dict(view_args)
for body_key, view_arg_name in (params or {}).items():
result.pop(view_arg_name, None)
result[body_key] = view_args[view_arg_name]
return result
def daemon_route(
endpoint: Endpoint,
bp: Blueprint,
rule: str | None = None,
methods: tuple[str, ...] | list[str] | None = None,
*,
params: dict[str, str] | None = None,
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
body: Any | None = None,
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
) -> Callable[..., Any]:
"""Decorator factory for thin daemon-proxy routes.
Args:
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
is the daemon HTTP verb.
bp: The target blueprint.
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
to the endpoint path minus its leading subsystem segment.
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
override where the UI verb differs from the daemon verb
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
params: ``{body_key: view_arg_name}`` renames for path params.
precheck: ``(json, view_args) -> None`` run before dispatch; raise
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
validation the daemon does not perform). ``json`` is the raw
``request.get_json(silent=True)`` result.
body: How to build the daemon request body for non-GET verbs.
``None`` (default): ``{**json, **mapped_view_args}``;
``NO_BODY``: send no body argument;
a callable ``(request, view_args) -> dict``: custom body (raise
``ValueError`` for 400);
a ``dict``: fixed body (merged with mapped view args).
transform: ``(data, view_args, sent_body) -> data`` applied to the
daemon result before wrapping in ``_ok()``; may raise a typed
exception to emit an error (e.g. 400 when a result is invalid).
Returns:
A decorator that registers the route and returns a view function
whose ``__name__``/``__doc__`` are inherited from the decorated
function.
"""
if rule is None:
rule = _derive_rule(endpoint[1])
if methods is None:
methods = [endpoint[0]]
daemon_method = endpoint[0]
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
# The decorated view lives in the blueprint's module; its ``__globals__``
# is that module's namespace. Resolving the daemon verb here (instead of
# ``daemon.client`` directly) means tests patching
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
module_globals = fn.__globals__
def view(**view_args: Any) -> Any:
try:
json: Any = request.get_json(silent=True)
if precheck is not None:
precheck(json, view_args)
mapped = _map_view_args(view_args, params)
verb_fn: Verb = module_globals[daemon_method.lower()]
if daemon_method == "GET":
sent_body = mapped
data = (
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
)
elif body is NO_BODY:
sent_body = None
data = verb_fn(endpoint)
elif callable(body):
built = body(request, view_args)
sent_body = built
data = (
verb_fn(endpoint, built)
if built is not None
else verb_fn(endpoint)
)
elif isinstance(body, dict):
sent_body = {**body, **mapped}
data = verb_fn(endpoint, sent_body)
else:
base = json if isinstance(json, dict) else {}
sent_body = {**base, **mapped}
data = verb_fn(endpoint, sent_body)
if transform is not None:
data = transform(data, view_args, sent_body)
return _ok(data)
except (BadRequest, ValueError) as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error(
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
)
return _error(str(exc), 500)
view.__name__ = fn.__name__
view.__doc__ = fn.__doc__
bp.add_url_rule(rule, view_func=view, methods=list(methods))
return view
return decorator
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
"""Precheck: reject a non-dict JSON body (400).
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
"""
if json is not None and not isinstance(json, dict):
raise ValueError("Request body must be a JSON object")
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
"""Transform: discard the daemon result and return ``data: null``.
Matches routes that historically responded ``_ok(None)`` (the daemon
result was intentionally ignored by the caller).
"""
return None
+137 -270
View File
@@ -3,11 +3,16 @@
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld. 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 ( from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE, DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE, DELETE_DNSMASQ_RANGES_REMOVE,
@@ -23,252 +28,139 @@ from daemon.iface import (
POST_DNSMASQ_RANGES_ADD, POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_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__) bp = Blueprint("dhcp", __name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config / status
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"]) @daemon_route(GET_DNSMASQ_CONFIG, bp)
def get_config_bp(): def get_config_bp():
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration. """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)
@bp.route("/config", methods=["POST"]) @daemon_route(
POST_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config(): def post_config():
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration. """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)
@bp.route("/config", methods=["PATCH"]) @daemon_route(
PATCH_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config(): def patch_config():
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration. """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)
@bp.route("/apply", methods=["POST"]) @daemon_route(POST_DNSMASQ_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp(): def apply_bp():
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service.""" """POST /api/dhcp/apply — Apply the current dnsmasq configuration."""
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)
# --------------------------------------------------------------------------- @daemon_route(GET_DNSMASQ_STATUS, bp)
# Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
def status_bp(): def status_bp():
"""GET /api/dhcp/status — Retrieve dnsmasq service status.""" """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 # 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(): def add_range_bp():
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface. """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)
@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(): def remove_range_bp():
"""DELETE /api/dhcp/ranges — Remove a DHCP address range. """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)
# --------------------------------------------------------------------------- @daemon_route(GET_DNSMASQ_LEASES, bp)
# Leases
# ---------------------------------------------------------------------------
@bp.route("/leases", methods=["GET"])
def leases_bp(): def leases_bp():
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table.""" """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 # Static leases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/static-lease", methods=["POST"]) def _add_static_lease_body(request: Any, _va: Any) -> dict[str, Any]:
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.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
mac = body.get("mac", "").strip() mac = (body.get("mac") or "").strip()
ip = body.get("ip", "").strip() ip = (body.get("ip") or "").strip()
hostname = body.get("hostname")
if not mac or not ip: if not mac or not ip:
return _error("'mac' and 'ip' are required", 400) raise ValueError("'mac' and 'ip' are required")
try: return {"mac": mac, "ip": ip, "hostname": body.get("hostname")}
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)
@bp.route("/static-lease/<mac>", methods=["DELETE"]) def _static_lease_echo(_data: Any, _va: Any, sent: Any) -> Any:
def remove_static_lease_bp(mac): return {"mac": sent["mac"], "ip": sent["ip"], "hostname": sent["hostname"]}
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
Args:
mac: MAC address of the static lease to remove.
Returns: @daemon_route(
JSON response with success status or an error. POST_DNSMASQ_STATIC_LEASE_ADD,
""" bp,
try: rule="/static-lease",
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac}) body=_add_static_lease_body,
logger.info("Static lease removed via API: %s", mac) transform=_static_lease_echo,
return _ok(None) )
except NotFound as exc: def add_static_lease_bp():
logger.info("Static lease '%s' not found: %s", mac, exc) """POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address."""
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove static lease '%s': %s", mac, exc) @daemon_route(
return _error(str(exc), 500) DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
bp,
rule="/static-lease/<mac>",
transform=void_transform,
)
def remove_static_lease_bp():
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -276,35 +168,42 @@ def remove_static_lease_bp(mac):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/dns-record", methods=["POST"]) def _add_dns_record_body(request: Any, _va: Any) -> dict[str, Any]:
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.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
name = body.get("name", "").strip() name = (body.get("name") or "").strip()
address = body.get("address", "").strip() address = (body.get("address") or "").strip()
hostname = body.get("hostname")
if not name or not address: if not name or not address:
return _error("'name' and 'address' are required", 400) raise ValueError("'name' and 'address' are required")
try: return {"name": name, "address": address, "hostname": body.get("hostname")}
post(
POST_DNSMASQ_DNS_RECORD_ADD,
{"name": name, "address": address, "hostname": hostname}, def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
) return {
logger.info("DNS record added via API: %s -> %s", name, address) "name": sent["name"],
return _ok({"name": name, "address": address, "hostname": hostname}) "address": sent["address"],
except BadRequest as exc: "hostname": sent["hostname"],
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) @daemon_route(
return _error(str(exc), 500) POST_DNSMASQ_DNS_RECORD_ADD,
bp,
rule="/dns-record",
body=_add_dns_record_body,
transform=_dns_record_echo,
)
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record."""
@daemon_route(
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
bp,
rule="/dns-record/<name>",
transform=void_transform,
)
def remove_dns_record_bp():
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -312,48 +211,16 @@ def add_dns_record_bp():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"]) def _set_domain_body(request: Any, _va: Any) -> dict[str, Any]:
return {"domain": (request.get_json(silent=True) or {}).get("domain")}
@daemon_route(
POST_DNSMASQ_DOMAIN,
bp,
precheck=require_dict_body,
body=_set_domain_body,
transform=void_transform,
)
def set_domain_bp(): def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain. """POST /api/dhcp/domain — Set or clear the DNS search domain."""
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
Args:
name: Name of the DNS record to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
logger.info("DNS record removed via API: %s", name)
return _ok(None)
except NotFound as exc:
logger.info("DNS record '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove DNS record '%s': %s", name, exc)
return _error(str(exc), 500)
+249 -524
View File
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
""" """
import logging 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 ( from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE, DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE, DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -30,169 +36,179 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES, POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES, 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__) logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __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) # Declarative config (two-step: save -> apply)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"]) @daemon_route(GET_FIREWALL_CONFIG, bp)
def config_list(): def config_list():
"""Retrieve the current firewall declarative configuration. """GET /api/firewall/config — Retrieve the current firewall config."""
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)
@bp.route("/config", methods=["POST"]) @daemon_route(
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
)
def config_save(): def config_save():
"""Save a new firewall declarative configuration. """POST /api/firewall/config — 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)
@bp.route("/config", methods=["PATCH"]) @daemon_route(
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
)
def patch_config(): def patch_config():
"""Partially update the firewall declarative configuration. """PATCH /api/firewall/config — Partially update the firewall 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)
@bp.route("/config/apply", methods=["POST"]) @daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
def config_apply_bp(): def config_apply_bp():
"""Apply any pending firewall configuration changes. """POST /api/firewall/config/apply — Apply pending firewall config 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)
@bp.route("/config/pending", methods=["GET"]) @daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
def config_pending_bp(): def config_pending_bp():
"""Check the pending firewall configuration state. """GET /api/firewall/config/pending — Check the pending firewall config 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -200,21 +216,9 @@ def config_pending_bp():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/state", methods=["GET"]) @daemon_route(GET_FIREWALL_STATE, bp)
def get_state(): def get_state():
"""Retrieve current firewall state from the state store. """GET /api/firewall/state — Retrieve current firewall state."""
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -222,182 +226,67 @@ def get_state():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/zones", methods=["GET"]) @daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
def list_zones(): def list_zones():
"""List all active and available firewall zones. """GET /api/firewall/zones — List 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)
@bp.route("/zones/<name>", methods=["GET"]) @daemon_route(
def zone_details(name: str): GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
"""Retrieve details for a specific firewall zone. )
def zone_details():
Endpoint: """GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
GET /api/firewall/zones/<name>
Args:
name: Name of the zone to look up.
Returns:
JSON with zone configuration details or 404 error.
"""
try:
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
return _ok(info)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500)
@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(): def create_zone_bp():
"""Create a new firewall zone. """POST /api/firewall/zones — 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)
@bp.route("/zones/<name>", methods=["DELETE"]) @daemon_route(
def delete_zone_bp(name: str): DELETE_FIREWALL_ZONES_DELETE,
"""Delete a firewall zone by name. bp,
rule="/zones/<name>",
Endpoint: params={"zone": "name"},
DELETE /api/firewall/zones/<name> transform=void_transform,
)
Args: def delete_zone_bp():
name: Name of the zone to delete. """DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Zone interfaces # Zone interfaces / services
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/zones/<name>/interfaces", methods=["POST"]) @daemon_route(
def set_zone_interfaces_bp(name: str): POST_FIREWALL_ZONES_INTERFACES,
"""Set the network interfaces assigned to a firewall zone. bp,
rule="/zones/<name>/interfaces",
Replaces all existing interfaces for the zone with the provided list. params={"zone": "name"},
precheck=_interfaces_precheck,
Endpoint: transform=_zone_interfaces_echo,
POST /api/firewall/zones/<name>/interfaces )
def set_zone_interfaces_bp():
Args: """POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
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(
# Zone services POST_FIREWALL_ZONES_SERVICES,
# --------------------------------------------------------------------------- bp,
rule="/zones/<name>/services",
params={"zone": "name"},
@bp.route("/zones/<name>/services", methods=["POST"]) precheck=_services_precheck,
def set_zone_services_bp(name: str): transform=_zone_services_echo,
"""Set the allowed services for a firewall zone. )
def set_zone_services_bp():
Replaces all existing services for the zone with the provided list. """POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
Endpoint:
POST /api/firewall/zones/<name>/services
Args:
name: Zone name.
body: JSON with ``services`` list of service names.
Returns:
JSON confirmation with zone and updated services list.
"""
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
return _error("'services' must be a list", 400)
try:
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
return _ok({"zone": name, "services": services})
except BadRequest as exc:
logger.info("Set services for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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(): def list_services():
"""List all available firewall services. """GET /api/firewall/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)
@bp.route("/interfaces", methods=["GET"]) @daemon_route(GET_FIREWALL_INTERFACES, bp)
def list_interfaces(): def list_interfaces():
"""List all available network interfaces. """GET /api/firewall/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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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(): def add_rich_rule_bp():
"""Add a rich rule to a firewall zone. """POST /api/firewall/rich-rules — 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)
@bp.route("/rich-rules/<zone>", methods=["GET"]) @daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
def list_rich_rules(zone: str): def list_rich_rules():
"""List rich rules for a specific firewall zone. """GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
Endpoint:
GET /api/firewall/rich-rules/<zone>
Args:
zone: Zone name to list rules for.
Returns:
JSON with list of rich rule entries for the zone.
"""
try:
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"]) @daemon_route(
def remove_rich_rule_bp(zone: str, rule_id: str): DELETE_FIREWALL_RICH_RULES_REMOVE,
"""Remove a rich rule from a firewall zone by ID. bp,
rule="/rich-rules/<zone>/<rule_id>",
Endpoint: params={"id": "rule_id"},
DELETE /api/firewall/rich-rules/<zone>/<rule_id> transform=_rich_rule_remove_echo,
)
Args: def remove_rich_rule_bp():
zone: Zone name. """DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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(): def set_masquerade_bp():
"""Enable or disable masquerade (NAT) on a firewall zone. """POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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(): def add_forward_port_bp():
"""Add a port forwarding rule to a firewall zone. """POST /api/firewall/forward-port — Add a port forwarding rule to a 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)
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"]) @daemon_route(
def remove_forward_port_bp(zone: str, port: int, proto: str): DELETE_FIREWALL_FORWARD_PORT_REMOVE,
"""Remove a port forwarding rule from a firewall zone. bp,
rule="/forward-port/<zone>/<int:port>/<proto>",
Endpoint: transform=_forward_port_remove_echo,
DELETE /api/firewall/forward-port/<zone>/<port>/<proto> )
def remove_forward_port_bp():
Args: """DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
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)
+7 -36
View File
@@ -3,11 +3,9 @@
Wraps raw log text in the standard JSON response contract. Wraps raw log text in the standard JSON response contract.
""" """
import logging
from flask import Blueprint 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 ( from daemon.iface import (
GET_LOGS_APP, GET_LOGS_APP,
GET_LOGS_DNSMASQ, GET_LOGS_DNSMASQ,
@@ -15,58 +13,31 @@ from daemon.iface import (
GET_LOGS_NGINX_ACCESS, GET_LOGS_NGINX_ACCESS,
GET_LOGS_NGINX_ERROR, 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 = Blueprint("logs", __name__)
@bp.route("/journal") @daemon_route(GET_LOGS_JOURNAL, bp)
def journal(): def journal():
"""GET /api/logs/journal — Return systemd journal log lines.""" """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(): def nginx_access():
"""GET /api/logs/nginx/access — Return nginx access log lines.""" """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(): def nginx_error():
"""GET /api/logs/nginx/error — Return nginx error log lines.""" """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(): def dnsmasq():
"""GET /api/logs/dnsmasq — Return dnsmasq log lines.""" """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(): def app_log():
"""GET /api/logs/app — Return application log lines.""" """GET /api/logs/app — Return application log lines."""
try:
return _ok(get(GET_LOGS_APP))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
+45 -130
View File
@@ -4,11 +4,14 @@ Exposes /api/network/* and delegates to vacuum-walld for interface
IP configuration via systemd-networkd. 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 ( from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES, GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES, GET_NETWORK_INFER_ZONES,
@@ -19,150 +22,62 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD, POST_NETWORK_INTERFACE_RELOAD,
) )
from lib.common import validate_interface_name 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 = 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(): def list_interfaces():
"""List all interfaces with their network config and runtime state. """GET /api/network/interfaces — List interfaces with config + 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)
@bp.route("/interfaces/<name>", methods=["GET"]) @daemon_route(GET_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface)
def get_interface(name: str): def get_interface():
"""Get config + runtime state for a specific interface. """GET /api/network/interfaces/<name> — Config + runtime state for one interface."""
Endpoint:
GET /api/network/interfaces/<name>
Returns:
JSON with interface config and runtime state.
"""
try:
validate_interface_name(name)
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Interface '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get interface '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/interfaces/<name>", methods=["POST"]) @daemon_route(
def save_interface(name: str): POST_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface, transform=_applied
"""Save and apply network config for an interface. )
def save_interface():
Endpoint: """POST /api/network/interfaces/<name> — Save and apply an interface's config."""
POST /api/network/interfaces/<name>
Args:
body: JSON with addresses, gateway, dns, routes.
Returns:
JSON confirmation.
"""
body = {**(request.get_json(silent=True) or {}), "name": name}
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_NAME, body)
logger.info("Interface '%s' config saved", name)
return _ok({"name": name, "applied": True})
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to save interface '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/interfaces/<name>/reload", methods=["POST"]) @daemon_route(
def reload_interface(name: str): POST_NETWORK_INTERFACE_RELOAD,
"""Reload networkd for a single interface. bp,
precheck=_check_iface,
Endpoint: body={},
POST /api/network/interfaces/<name>/reload transform=_reloaded,
)
Returns: def reload_interface():
JSON confirmation. """POST /api/network/interfaces/<name>/reload — Reload networkd for one interface."""
"""
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)
@bp.route("/apply", methods=["POST"]) @daemon_route(POST_NETWORK_APPLY, bp)
def apply_all(): def apply_all():
"""Apply network config for ALL interfaces (full sync). """POST /api/network/apply — Apply network config for ALL interfaces."""
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)
@bp.route("/infer-dhcp-ranges", methods=["GET"]) @daemon_route(GET_NETWORK_INFER_DHCP_RANGES, bp)
def infer_dhcp_ranges(): def infer_dhcp_ranges():
"""Suggest candidate DHCP ranges based on static interface IPs. """GET /api/network/infer-dhcp-ranges — Suggest candidate DHCP ranges."""
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)
@bp.route("/infer-zones", methods=["GET"]) @daemon_route(GET_NETWORK_INFER_ZONES, bp)
def infer_zones(): def infer_zones():
"""Suggest firewalld zone assignments for configured interfaces. """GET /api/network/infer-zones — Suggest firewalld zone assignments."""
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)
+112 -296
View File
@@ -3,11 +3,17 @@
Exposed at /api/proxy/* and delegates to vacuum-walld. 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 ( from daemon.iface import (
DELETE_NGINX_BACKENDS_REMOVE, DELETE_NGINX_BACKENDS_REMOVE,
DELETE_NGINX_DOMAINS_REMOVE, DELETE_NGINX_DOMAINS_REMOVE,
@@ -24,144 +30,59 @@ from daemon.iface import (
POST_NGINX_SSL_APPLY, POST_NGINX_SSL_APPLY,
POST_NGINX_TEST, 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 = 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(): def ssl_apply_bp():
"""Apply SSL snippet config. """POST /api/proxy/ssl-apply — Apply the 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)
@bp.route("/config", methods=["GET"]) @daemon_route(GET_NGINX_CONFIG, bp)
def get_config_bp(): def get_config_bp():
"""Get the current nginx proxy configuration. """GET /api/proxy/config — 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)
@bp.route("/config", methods=["POST"]) @daemon_route(
POST_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config(): def post_config():
"""Save the nginx proxy configuration. """POST /api/proxy/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)
@bp.route("/config", methods=["PATCH"]) @daemon_route(
PATCH_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config(): def patch_config():
"""Partially update the nginx proxy configuration. """PATCH /api/proxy/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)
@bp.route("/domains", methods=["GET"]) @daemon_route(GET_NGINX_DOMAINS, bp)
def list_domains(): def list_domains():
"""List all configured proxy domains. """GET /api/proxy/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)
@bp.route("/domains", methods=["POST"]) # ---------------------------------------------------------------------------
def add_domain_bp(): # Domain CRUD
"""Add a new proxy domain referencing a backend. # ---------------------------------------------------------------------------
POST /api/proxy/domains
Body fields: def _add_domain_body(request: Any, _va: Any) -> dict[str, Any]:
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.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip() domain = (body.get("domain") or "").strip()
if not domain: if not domain:
return _error("'domain' is required", 400) raise ValueError("'domain' is required")
backend = body.get("backend", "").strip() backend = (body.get("backend") or "").strip()
if not backend: if not backend:
return _error("'backend' is required", 400) raise ValueError("'backend' is required")
payload: dict[str, Any] = {
payload = {
"domain": domain, "domain": domain,
"backend": backend, "backend": backend,
"force_ssl": body.get("force_ssl", True), "force_ssl": body.get("force_ssl", True),
@@ -170,105 +91,62 @@ def add_domain_bp():
payload["cert"] = body["cert"] payload["cert"] = body["cert"]
if body.get("auth") is not None: if body.get("auth") is not None:
payload["auth"] = body["auth"] payload["auth"] = body["auth"]
return payload
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)
@bp.route("/domains/<domain>", methods=["PUT"]) def _domain_echo(_data: Any, _va: Any, sent: Any) -> Any:
def update_domain_bp(domain): return {"domain": sent.get("domain")}
"""Update an existing proxy domain in-place.
PUT /api/proxy/domains/<domain>
Body fields:
Fields to merge into the domain config.
Returns:
``{"domain": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not body:
return _error("Request body must be a JSON object with fields to update", 400)
try:
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain})
except BadRequest as exc:
logger.info("Update domain '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Domain '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/domains/<domain>", methods=["DELETE"]) @daemon_route(
def remove_domain_bp(domain): POST_NGINX_DOMAINS_ADD,
"""Remove a proxy domain. bp,
rule="/domains",
DELETE /api/proxy/domains/<domain> body=_add_domain_body,
transform=_domain_echo,
Returns: )
``{"domain": ...}`` on success. def add_domain_bp():
""" """POST /api/proxy/domains — Add a new proxy domain referencing a backend."""
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)
@bp.route("/apply", methods=["POST"]) def _update_domain_precheck(json: Any, _va: Any) -> None:
if not json:
raise ValueError("Request body must be a JSON object with fields to update")
@daemon_route(
POST_NGINX_DOMAINS_UPDATE,
bp,
rule="/domains/<domain>",
methods=["PUT"],
precheck=_update_domain_precheck,
transform=_domain_echo,
)
def update_domain_bp():
"""PUT /api/proxy/domains/<domain> — Update an existing proxy domain in-place."""
@daemon_route(
DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/<domain>", transform=_domain_echo
)
def remove_domain_bp():
"""DELETE /api/proxy/domains/<domain> — Remove a proxy domain."""
@daemon_route(POST_NGINX_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp(): def apply_bp():
"""Generate all nginx configs and reload nginx. """POST /api/proxy/apply — 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)
@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(): def test_bp():
"""Test nginx configuration without reloading. """POST /api/proxy/test — 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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(): def list_backends():
"""List all configured backends. """GET /api/proxy/backends — List all configured backends (secrets stripped)."""
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)
@bp.route("/backends", methods=["PATCH"]) @daemon_route(
PATCH_NGINX_BACKENDS, bp, precheck=require_dict_body, transform=_backend_echo
)
def patch_backend_bp(): def patch_backend_bp():
"""Partially update a backend entry. """PATCH /api/proxy/backends — 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)
@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(): def add_backend_bp():
"""Add a new backend. """POST /api/proxy/backends — 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)
@bp.route("/backends/<name>", methods=["DELETE"]) @daemon_route(
def remove_backend_bp(name): DELETE_NGINX_BACKENDS_REMOVE, bp, rule="/backends/<name>", transform=_backend_echo
"""Remove a non-builtin backend. )
def remove_backend_bp():
DELETE /api/proxy/backends/<name> """DELETE /api/proxy/backends/<name> — Remove a non-builtin backend."""
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)
+16 -83
View File
@@ -3,13 +3,12 @@
Exposed at /api/status/* and delegates all operations to vacuum-walld. Exposed at /api/status/* and delegates all operations to vacuum-walld.
""" """
from __future__ import annotations from flask import Blueprint
import logging from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
get,
from flask import Blueprint, request post,
)
from daemon.client import get, post
from daemon.iface import ( from daemon.iface import (
GET_STATUS_PENDING, GET_STATUS_PENDING,
GET_SYSTEM_METRICS, GET_SYSTEM_METRICS,
@@ -17,97 +16,31 @@ from daemon.iface import (
POST_STATUS_CANCEL_ALL, POST_STATUS_CANCEL_ALL,
POST_STATUS_REFRESH, 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 = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"]) @daemon_route(GET_STATUS_PENDING, bp)
def pending(): def pending():
"""Retrieve aggregate pending changes across all subsystems. """GET /api/status/pending — Per-subsystem pending status + total change count."""
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)
@bp.route("/apply-all", methods=["POST"]) @daemon_route(POST_STATUS_APPLY_ALL, bp)
def apply_all(): def apply_all():
"""Apply pending changes for all subsystems in dependency order. """POST /api/status/apply-all — Apply pending changes 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)
@bp.route("/cancel-all", methods=["POST"]) @daemon_route(POST_STATUS_CANCEL_ALL, bp, body=NO_BODY)
def cancel_all(): def cancel_all():
"""Revert pending changes for all subsystems to the last applied config. """POST /api/status/cancel-all — Revert pending changes to 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)
@bp.route("/refresh", methods=["POST"]) @daemon_route(POST_STATUS_REFRESH, bp)
def refresh(): def refresh():
"""Re-collect state from the daemon, optionally filtered by subsystem. """POST /api/status/refresh — Re-collect state, optionally filtered by subsystem."""
Endpoint:
POST /api/status/refresh
Body:
{"subsystems": ["firewall"]} or {} for all.
"""
body = request.get_json(silent=True) or {}
try:
return _ok(post(POST_STATUS_REFRESH, body))
except RuntimeError as exc:
logger.error("Failed to refresh state: %s", exc)
return _error(str(exc), 500)
@bp.route("/system-metrics", methods=["GET"]) @daemon_route(GET_SYSTEM_METRICS, bp, rule="/system-metrics")
def system_metrics(): def system_metrics():
"""Retrieve system-wide metrics. """GET /api/status/system-metrics — System-wide CPU/memory/network 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)
+215 -436
View File
@@ -3,11 +3,16 @@
Exposed at /api/wireguard/* and delegates to vacuum-walld. 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 ( from daemon.iface import (
DELETE_WIREGUARD_CLASSES, DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN, DELETE_WIREGUARD_CLASSES_DOWN,
@@ -30,473 +35,247 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE, POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD, 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 = Blueprint("wireguard", __name__)
@bp.route("/config", methods=["GET"]) # ---------------------------------------------------------------------------
def get_config_bp(): # Body builders / prechecks / transforms
"""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)
@bp.route("/config", methods=["POST"]) def _wg_config_body(request: Any, _va: Any) -> dict[str, Any]:
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.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
if not isinstance(body, dict): if "interface" in body:
return _error("Request body must be a JSON object", 400) body = dict(body)
try: body["interface"] = dict(body["interface"])
if "interface" in body: body["interface"].pop("private_key", None)
body = dict(body) return 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)
@bp.route("/config", methods=["PATCH"]) def _add_peer_body(request: Any, _va: Any) -> dict[str, Any]:
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.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
if not isinstance(body, dict): name = (body.get("name") or "").strip()
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()
if not name: if not name:
return _error("'name' is required", 400) raise ValueError("'name' is required")
try: return {
peer = post( "name": name,
POST_WIREGUARD_PEERS_ADD, "endpoint": body.get("endpoint"),
{ "allowed_ips": body.get("allowed_ips", []),
"name": name, "persistent_keepalive": body.get("persistent_keepalive"),
"endpoint": body.get("endpoint"), "preshared_key": body.get("preshared_key"),
"allowed_ips": body.get("allowed_ips", []), "description": body.get("description"),
"persistent_keepalive": body.get("persistent_keepalive"), "access_class": body.get("access_class"),
"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)
@bp.route("/peers/<name>", methods=["DELETE"]) def _gen_client_body(request: Any, _va: Any) -> dict[str, Any]:
def remove_peer_bp(name):
"""Remove a peer from the WireGuard configuration.
Endpoint: DELETE /api/wireguard/peers/<name>
Args:
name: Peer name to remove (from URL path).
Returns:
Success response with peer name on removal, 404 if peer not found,
or 500 on server error.
"""
try:
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name})
except NotFound as exc:
logger.info("WireGuard peer '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove peer '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/peers", methods=["GET"])
def peers_bp():
"""List all configured WireGuard peers.
Endpoint: GET /api/wireguard/peers
Returns:
JSON response with the peers list on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEERS))
except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500)
@bp.route("/peer-status", methods=["GET"])
def peer_status_bp():
"""Get real-time status information for all WireGuard peers.
Endpoint: GET /api/wireguard/peer-status
Returns:
JSON response with peer status on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEER_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500)
@bp.route("/generate-client", methods=["POST"])
def generate_client_bp():
"""Generate a WireGuard client configuration file for a peer.
Endpoint: POST /api/wireguard/generate-client
Args:
name: Peer name (required).
server_endpoint: Server endpoint address for the client config (required).
Returns:
JSON response with the generated config string on success, 404 if
peer not found, 400 on validation failure, or 500 on server error.
"""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
name = body.get("name", "").strip() name = (body.get("name") or "").strip()
if not name: if not name:
return _error("Field 'name' is required", 400) raise ValueError("Field 'name' is required")
server_endpoint = body.get("server_endpoint", "") server_endpoint = body.get("server_endpoint", "")
if not server_endpoint: if not server_endpoint:
return _error("Field 'server_endpoint' is required", 400) raise ValueError("Field 'server_endpoint' is required")
try: return {"name": name, "server_endpoint": server_endpoint}
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)
@bp.route("/classes", methods=["GET"]) def _delete_class_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
key = (body.get("key") or "").strip()
if not key:
raise ValueError("'key' is required")
return {"key": key}
def _class_key_precheck(json: Any, va: dict[str, Any]) -> None:
require_dict_body(json, va)
if not ((json or {}).get("key") or "").strip():
raise ValueError("'key' is required")
def _peer_name_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"name": sent["name"]}
def _config_echo(data: Any, _va: Any, _sent: Any) -> Any:
return {"config": data.get("config", "")}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CONFIG, bp)
def get_config_bp():
"""GET /api/wireguard/config — Get the current WireGuard configuration."""
@daemon_route(
POST_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def post_config():
"""POST /api/wireguard/config — Create or fully replace the configuration."""
@daemon_route(
PATCH_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def patch_config():
"""PATCH /api/wireguard/config — Partially update the configuration."""
# ---------------------------------------------------------------------------
# Tunnel control
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""POST /api/wireguard/apply — Apply the current configuration to the tunnel."""
@daemon_route(
POST_WIREGUARD_APPLY, bp, rule="/up", body=NO_BODY, transform=void_transform
)
def up_bp():
"""POST /api/wireguard/up — Bring the WireGuard tunnel interface up."""
@daemon_route(POST_WIREGUARD_DOWN, bp, body=NO_BODY, transform=void_transform)
def down_bp():
"""POST /api/wireguard/down — Bring the WireGuard tunnel interface down."""
@daemon_route(GET_WIREGUARD_STATUS, bp)
def status_bp():
"""GET /api/wireguard/status — Get the current WireGuard tunnel status."""
@daemon_route(POST_WIREGUARD_INITIALIZE, bp, body=NO_BODY, transform=void_transform)
def initialize_bp():
"""POST /api/wireguard/initialize — Initialize WireGuard for first-time use."""
# ---------------------------------------------------------------------------
# Peers
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_PEERS_ADD, bp, rule="/peers", body=_add_peer_body)
def add_peer_bp():
"""POST /api/wireguard/peers — Add a new peer to the configuration."""
@daemon_route(
DELETE_WIREGUARD_PEERS_REMOVE, bp, rule="/peers/<name>", transform=_peer_name_echo
)
def remove_peer_bp():
"""DELETE /api/wireguard/peers/<name> — Remove a peer from the configuration."""
@daemon_route(GET_WIREGUARD_PEERS, bp)
def peers_bp():
"""GET /api/wireguard/peers — List all configured WireGuard peers."""
@daemon_route(GET_WIREGUARD_PEER_STATUS, bp)
def peer_status_bp():
"""GET /api/wireguard/peer-status — Get real-time status for all peers."""
@daemon_route(
POST_WIREGUARD_GENERATE_CLIENT, bp, body=_gen_client_body, transform=_config_echo
)
def generate_client_bp():
"""POST /api/wireguard/generate-client — Generate a client config for a peer."""
# ---------------------------------------------------------------------------
# Access classes
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CLASSES, bp)
def list_classes_bp(): def list_classes_bp():
"""List all access classes. """GET /api/wireguard/classes — 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)
@bp.route("/classes", methods=["POST"]) @daemon_route(POST_WIREGUARD_CLASSES, bp, precheck=_class_key_precheck)
def create_class_bp(): def create_class_bp():
"""Create a new access class. """POST /api/wireguard/classes — 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)
@bp.route("/classes", methods=["PATCH"]) @daemon_route(
PATCH_WIREGUARD_CLASSES, bp, rule="/classes", precheck=_class_key_precheck
)
def update_class_bp(): def update_class_bp():
"""Update an access class. """PATCH /api/wireguard/classes — 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)
@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(): def delete_class_bp():
"""Delete an access class. """DELETE /api/wireguard/classes — 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)
@bp.route("/classes/<key>/up", methods=["POST"]) @daemon_route(
def class_up_bp(key): POST_WIREGUARD_CLASSES_UP,
"""Bring up a single access class's WireGuard tunnel. bp,
rule="/classes/<key>/up",
Endpoint: POST /api/wireguard/classes/<key>/up params={"class_key": "key"},
""" body={},
try: transform=void_transform,
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key}) )
logger.info("WireGuard class '%s' tunnel brought up via API", key) def class_up_bp():
return _ok(None) """POST /api/wireguard/classes/<key>/up — Bring up a class's tunnel."""
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/down", methods=["POST"]) @daemon_route(
def class_down_bp(key): DELETE_WIREGUARD_CLASSES_DOWN,
"""Bring down a single access class's WireGuard tunnel. bp,
rule="/classes/<key>/down",
Endpoint: POST /api/wireguard/classes/<key>/down methods=["POST"],
""" params={"class_key": "key"},
try: body={},
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key}) transform=void_transform,
logger.info("WireGuard class '%s' tunnel brought down via API", key) )
return _ok(None) def class_down_bp():
except RuntimeError as exc: """POST /api/wireguard/classes/<key>/down — Bring down a class's tunnel."""
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/status", methods=["GET"]) @daemon_route(
def class_status_bp(key): GET_WIREGUARD_CLASS_STATUS,
"""Get status for a single access class's tunnel. bp,
rule="/classes/<key>/status",
Endpoint: GET /api/wireguard/classes/<key>/status params={"class_key": "key"},
""" )
try: def class_status_bp():
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key})) """GET /api/wireguard/classes/<key>/status — Get status for a class's tunnel."""
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/keys/<key>", methods=["POST"]) @daemon_route(
def class_init_keys_bp(key): POST_WIREGUARD_CLASS_INIT_KEYS,
"""Generate key pair for a single access class. bp,
rule="/classes/keys/<key>",
Endpoint: POST /api/wireguard/classes/keys/<key> params={"class_key": "key"},
""" body={},
try: transform=void_transform,
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key}) )
logger.info("WireGuard class '%s' keys generated via API", key) def class_init_keys_bp():
return _ok(None) """POST /api/wireguard/classes/keys/<key> — Generate keys for a class."""
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)
@@ -151,7 +151,7 @@ async function openApplyModal(successMsg) {
<div class="modal-body">${rows}</div> <div class="modal-body">${rows}</div>
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer"> ${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" /> <input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
<span class="text-sm">Force apply <span class="text-muted"> overrides firewall safety guards (e.g. leaving an interface in no zone, or removing https/ssh from the default zone)</span></span> <span class="text-sm">Force apply <span class="text-muted"> overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
</label>` : ''} </label>` : ''}
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div> <div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
</div>`); </div>`);