Compare commits

...

6 Commits

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

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

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

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

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

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

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

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

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

tests: 888 pytest + 24 node passing; ruff clean
2026-09-01 02:35:04 +00:00
85 changed files with 4391 additions and 3907 deletions
+25 -4
View File
@@ -39,10 +39,15 @@ Basic auth (`.htpasswd`) renders **only** for proxy domains whose
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
- `daemon/handlers/*.py` — Privileged operation handlers. All mutating `sudo` calls live here.
- `daemon/collectors/` — Per-subsystem state collectors (7 modules: firewall, dnsmasq, nginx, acme, wireguard, networkd, system). Read-only `sudo` queries that populate `lib.state`. Imported for their registration side-effect; `daemon/server.py` imports the package before the first `populate()`.
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`, plus the apply-bookkeeping helpers `stamp_applied()` / `strip_apply_meta()` / `compute_pending()` / `deep_diff()` / `revert_to_applied()` (the `_last_applied_hash` / `_last_applied_config` keys every config-backed subsystem uses for pending-change detection and cancel-all).
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
- `lib/sync.py` — Cross-subsystem sync event bus (in-process pub/sub); handlers emit events on mutation and subscribers refresh affected subsystems.
- `lib/system_import.py` — Startup system-config import/reconcile: parses native config sources and merges them into the declarative JSON on daemon start.
- `lib/bootstrap.py` — Daemon-startup filesystem bootstrap, run **after** `system_import.import_all()` (which must see absent config files to adopt live state on first start) and before the first state collection: creates the runtime `config/`+`data/` directories and persists the one-shot nginx legacy-format migration. Never creates config files (reads stay pure; files appear on first `save_config`).
- `lib/schema.py` — TypedDict state schemas for the per-subsystem state payloads.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
@@ -63,7 +68,7 @@ Conventions:
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
- No build step — ES modules served raw. Cache controlled via HTTP headers.
- No build step — ES modules served raw. Cache controlled via HTTP headers. For the management domain, nginx serves `/static/` directly from `webui/static/` (generated `location /static/` alias with `no-cache` + ETag revalidation); Flask's static route is the dev-mode fallback.
### Daemon Endpoints
@@ -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.
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
**Config reads are pure.** Every `lib/<subsystem>.get_config()` is a side-effect-free
read (returns in-memory defaults when the file is missing; nginx applies its
legacy-format migration in memory). State collectors therefore never write to
disk — filesystem setup (runtime dirs, one-shot nginx migration) happens once at
daemon startup in `lib/bootstrap.py` (after `system_import.import_all()`).
**Firewall interface-coverage invariant.** Every network-managed interface
(`lo`/`wg*` excluded) must be covered by a zone in `config/firewall/config.json`
or listed under the top-level `unmanaged` key. The config is the source of truth
for zone interfaces (an omitted `interfaces` key = empty list; no hands-off
zones). Enforced at save time (`POST`/`PATCH /firewall/config` → 400) and apply
time (`POST /firewall/config/apply` → 409, `force: true` overrides) via the pure
`lib.firewall.validate_coverage()`. Live drift is advisory only
(`uncovered_interfaces` state field). See `docs/config.md`.
## API Response Contract
- Success: `{"ok": true, "data": <value>}``_ok(data)` (Flask) or `ok(data)` (aiohttp)
@@ -140,7 +160,7 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
**Tests:** pytest in `tests/` (27 test files). All subprocess calls are mocked — no system services required.
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
@@ -160,4 +180,5 @@ user (full `rw` on all subsystems; default username `admin`), **not** an nginx h
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/hoover.md` | Custom frontend framework API reference |
| `docs/state-model.md` | Per-subsystem state schema, the `versions`/`tick` two-layer diff, and the pending-changes model |
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
-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",
}
),
)
+64 -13
View File
@@ -54,6 +54,52 @@ _ACME_ENVIRON = {
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def normalize_acme_home() -> None:
"""Restore group access on the ACME home files around acme.sh runs.
acme.sh hardens its tree on every run (``chmod 700`` on the config
home, ``chmod 600`` on keys and confs, owned by the running user).
The daemon reopens group read/write via the sudoers whitelist so
the shared two-user model keeps the tree readable. Run BEFORE an
acme.sh invocation too: acme.sh dot-sources ``account.conf`` on
startup, so a tree left owner-only by another user's run (e.g. a
manual debug run as the WebUI user) would make every daemon acme.sh
call exit 2 — normalizing first is the only self-heal path, since a
post-run normalize is unreachable while acme.sh cannot start.
Files only: the directories in the tree are setgid (2775, group rwx
already), and chmodding a setgid directory issues fchmodat with the
S_ISGID bit set, which the unit's ``RestrictSUIDSGID=yes`` seccomp
filter rejects with EPERM even for root.
"""
files = [str(p) for p in _ACME_HOME.rglob("*") if p.is_file()]
if not files:
return
result = lib_common.run_proc(
["chmod", "g+rwX", *files],
sudo=True,
check=False,
timeout=10,
)
if result.returncode != 0:
logger.warning(
"Could not normalize ACME_HOME permissions: %s",
result.stderr.strip() or f"exit code {result.returncode}",
)
def _run_acme_preflight(args: list[str]) -> str:
"""Normalize ACME home permissions, then run acme.sh with *args*.
Single choke point for every daemon acme.sh invocation: the
preflight normalize makes the run succeed even if a prior run by
another user left the tree owner-only.
"""
normalize_acme_home()
return _run_acme(args)
# In-memory store for active issuance requests.
_ISSUANCES: dict[str, "IssueRequest"] = {}
@@ -546,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -561,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
def _check_account_registered() -> tuple[bool, str]:
"""Blocking check: verify an ACME account is registered.
Delegates to ``lib.state._parse_account_conf()`` which checks both
Delegates to ``daemon.collectors.acme._parse_account_conf()`` which checks both
the legacy .account.conf and the declarative config/acme/config.json
used by modern acme.sh (v3.x).
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -576,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
def _get_account_info() -> dict[str, Any]:
"""Read and return the ACME account info dict.
Delegates to ``lib.state._parse_account_conf()`` for a single
Delegates to ``daemon.collectors.acme._parse_account_conf()`` for a single
source of truth.
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
return _parse_account_conf(_ACME_HOME)
@@ -839,14 +885,16 @@ async def _run_issue(req: IssueRequest) -> None:
args.append("--force")
# acme.sh is a blocking subprocess — run it off the event loop so
# polling, WS broadcasts, and other requests keep responding.
output = await asyncio.to_thread(_run_acme, args)
output = await asyncio.to_thread(_run_acme_preflight, args)
normalize_acme_home()
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
_run_acme_preflight,
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -951,7 +999,9 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
args: list[str] = ["--renew", "-d", req.domain]
if force:
args.append("--force")
output = await asyncio.to_thread(_run_acme, args)
output = await asyncio.to_thread(_run_acme_preflight, args)
# acme.sh hardens its tree even when it skips — normalize first.
normalize_acme_home()
if "Skipping." in output:
req.steps[0].status = "done"
req.steps[0].message = "Renewal not yet due — skipped"
@@ -968,7 +1018,8 @@ async def _run_renew(req: IssueRequest, force: bool) -> None:
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
_run_acme_preflight,
["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK],
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -1003,7 +1054,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
_run_acme(["--remove", "-d", domain])
_run_acme_preflight(["--remove", "-d", domain])
logger.info("Certificate for %s removed", domain)
refresh_state(["acme"])
return {"domain": domain}
@@ -1021,7 +1072,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
email = body.get("email", "").strip()
if not email:
raise ValueError("'email' is required")
_run_acme(["--register-account", "-m", email])
_run_acme_preflight(["--register-account", "-m", email])
# Persist to declarative ACME config
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
@@ -1159,7 +1210,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
raise ValueError("Invalid email format")
server = (body.get("server") or "letsencrypt").strip()
_run_acme(["--register-account", "-m", email, "--server", server])
_run_acme_preflight(["--register-account", "-m", email, "--server", server])
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
@@ -1179,7 +1230,7 @@ def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, An
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
try:
_run_acme(["--deactivate-account"])
_run_acme_preflight(["--deactivate-account"])
except RuntimeError as exc:
logger.warning("acme.sh deactivate failed: %s", exc)
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
+26
View File
@@ -0,0 +1,26 @@
"""Shared helpers for daemon handlers."""
from typing import Any
from daemon.server import refresh_state
from lib.sync import SyncEvent, bus
def emit_and_refresh(
subsystem: str, payload: dict[str, Any] | None = None
) -> list[str]:
"""Emit a ``config_saved`` sync event and refresh the affected state.
All mutation handlers end with the same tail: emit the event, refresh
the source subsystem plus every subsystem the sync touched.
Args:
subsystem: Source subsystem name.
payload: Event payload (e.g. ``{"action": "zone_created"}``).
Returns:
Subsystems affected by the sync event.
"""
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
refresh_state([subsystem, *sync_result.affected_subsystems])
return sync_result.affected_subsystems
+16 -75
View File
@@ -8,6 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
@@ -24,7 +25,7 @@ from daemon.iface import (
POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import (
deep_merge,
ensure_dirs,
@@ -35,7 +36,6 @@ from lib.common import (
stamp_applied,
strip_apply_meta,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body:
raise ValueError("Request body required")
_save_config(body)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_saved"})
return {"config_saved": True}
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_patched"})
return {"config_saved": True}
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
return {"applied": True, "synced": synced}
@registry.register(GET_DNSMASQ_STATUS)
@@ -281,12 +272,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
entry["dns"] = body["dns"]
ranges.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -321,12 +307,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -365,26 +346,14 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
if hostname is not None:
leases[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
@@ -409,12 +378,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
return {"mac": mac}
@@ -440,26 +404,14 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
if hostname is not None:
records[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
@@ -482,12 +434,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
return {"name": name}
@@ -503,10 +450,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
return {"upstreams": cfg["dns"]["upstreams"]}
@@ -523,8 +467,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "domain_set"})
return {"domain": cfg["dns"]["domain"]}
+105 -110
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -33,7 +34,7 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
@@ -43,11 +44,11 @@ from lib.firewall import (
_parse_all_zones_output,
_parse_zone_output,
fw_change_summary,
validate_coverage,
)
from lib.firewall import (
save_backup as _save_backup,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg, indent=2)
def _check_coverage(cfg: dict[str, Any]) -> None:
"""Reject a config that leaves a managed interface without coverage.
Runs the pure ``validate_coverage`` invariant against the current
network config. ``lo`` and ``wg*`` are exempt, and interfaces declared
in the top-level ``unmanaged`` list are exempt.
Args:
cfg: The (merged or full) firewall config dict to validate.
Raises:
ValueError: If a network-managed interface is not covered by any
zone and is not declared under ``unmanaged``.
"""
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ValueError(
"Refusing to save: "
f"{', '.join(repr(n) for n in uncovered)} "
f"have no firewall zone coverage and are not declared in the "
f"'unmanaged' list. Assign each interface to a zone, or add it "
f"to the top-level 'unmanaged' list."
)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
@@ -150,11 +176,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so
their current live interfaces count as coverage, as do the live
interfaces of zones that are live but absent from the config.
- the config leaves a network-subsystem-managed interface with no
firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
The config is the source of truth for zone interfaces — an absent
``interfaces`` key counts as empty — so coverage is computed from the
config alone via ``validate_coverage`` with no live-state fallback.
Interfaces listed in the top-level ``unmanaged`` key are exempt. The
same invariant is enforced at save time (POST/PATCH /firewall/config),
so a conflict here means the network config changed after the firewall
config was saved (e.g. a new interface no zone covers).
"""
from lib.firewall import get_config as _get_lib_config
@@ -178,37 +208,20 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
f'to the zone\'s services, or pass {{"force": true}}.'
)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
# Coverage invariant: every network-managed interface must be
# covered by a zone in the config (or declared unmanaged), or
# traffic (and DHCP) on that segment is dropped. Pure config check
# — the config is the source of truth, so no live-state comparison.
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
f"have no firewall zone coverage in the config and are not "
f"declared unmanaged, so all traffic (including DHCP) from "
f"those segments would be dropped. Assign each interface to "
f"a zone (or list it under the config's top-level 'unmanaged' "
f'key), or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
@@ -297,16 +310,14 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
# The config is the source of truth: an absent "interfaces" key
# counts as an empty list (unassign-all), matching the coverage
# invariant and the pending diff.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
),
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("interfaces", [])
for iface in current_ifaces:
run(
@@ -576,19 +587,20 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty, missing ``zones`` key,
or ``zones`` is not a dict.
ValueError: If body is empty, missing ``zones`` key, ``zones`` is
not a dict, ``unmanaged`` is not a list, or the config leaves a
network-managed interface without zone coverage.
"""
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
_check_coverage(body)
_save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_saved"})
return {"config_saved": True}
@@ -604,20 +616,22 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty.
ValueError: If body is empty, ``unmanaged`` is not a list, or the
merged config leaves a network-managed interface without zone
coverage.
"""
if not body:
raise ValueError("Request body must be a JSON object")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_check_coverage(merged)
_save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys()))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_patched"})
return {"config_saved": True}
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
default zone, or would remove zone coverage from a
network-managed interface that is covered now, and ``force`` is
not set.
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
result["synced"] = sync_result.affected_subsystems
synced = emit_and_refresh("firewall", {"action": "config_applied"})
result["synced"] = synced
return result
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
return {"zone": zone_name}
@@ -754,10 +761,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Zone '%s' deleted", zone)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
return {"zone": zone}
@@ -856,15 +860,13 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
old_zone_cfg["interfaces"] = new_ifaces
elif "interfaces" in old_zone_cfg:
del old_zone_cfg["interfaces"]
# This mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
return {"zone": zone, "interfaces": interfaces}
@@ -927,15 +929,15 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
_reload()
# Keep the declarative config in sync so the next apply does not
# reconcile the live services back to the stale config value.
# reconcile the live services back to the stale config value. The
# mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
return {"zone": zone, "services": services}
@@ -979,13 +981,9 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
rule_id = uuid4().hex[:8]
entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
return {"zone": zone, "id": rule_id, "rule": rule}
@@ -1035,13 +1033,9 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
zone_cfg["rich_rules"] = [
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
return {"zone": zone, "id": rule_id}
@@ -1084,6 +1078,10 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Enable/disable masquerade on a zone.
Also syncs the declarative config (and re-stamps the applied baseline)
when the zone exists in the config, so the pending diff and cancel-all
stay consistent with the live zone.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``enable`` (boolean).
@@ -1108,12 +1106,17 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
# Keep the declarative config in sync with the live zone so the pending
# diff and the cancel-all baseline stay consistent. Only touch zones that
# already exist in the config — creating a bare zone entry would
# manufacture spurious service/interface diffs on the next poll.
cfg = _get_config()
zone_cfg = cfg.get("zones", {}).get(zone)
if isinstance(zone_cfg, dict):
zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg)
_save_config(cfg)
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
return {"zone": zone, "masquerade": bool(enable)}
@@ -1161,20 +1164,16 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
_reload()
fp_id = uuid4().hex[:8]
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
if toaddr:
if toaddr and toport:
entry["toaddr"] = toaddr
if toport:
entry["toport"] = int(toport)
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@@ -1233,13 +1232,9 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
return {"zone": zone, "port": int(port), "proto": proto}
+8 -17
View File
@@ -10,6 +10,7 @@ import re
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
@@ -20,7 +21,7 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import run, stamp_applied, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
@@ -36,7 +37,6 @@ from lib.network import (
render_network_file,
save_config,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -220,16 +220,13 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
synced = emit_and_refresh(
"networkd", {"action": "interface_saved", "interface": name}
)
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {
"name": name,
"applied": deployed,
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -298,10 +295,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
synced = emit_and_refresh("networkd", {"action": "config_applied"})
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
@@ -312,7 +306,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -366,8 +360,5 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
return {"name": name, "value": value}
+1
View File
@@ -293,6 +293,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any])
cert_key_path=cert_key_path,
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
has_management=has_management,
static_root=str(PROJECT_DIR / "webui" / "static"),
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
+12 -1
View File
@@ -128,12 +128,20 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
Args:
_request: The incoming HTTP request (unused).
_body: Optional JSON body; ``{"force": true}`` is forwarded to the
firewall apply, overriding its management-lockout and
interface-coverage guards. Other subsystems ignore it.
Returns:
Dict with applied subsystems and any errors encountered.
"""
applied = []
errors = {}
force = bool(_body and _body.get("force"))
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
@@ -153,7 +161,10 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
handler = SYS_APPLY[name]
try:
handler(None, None)
# Only the firewall apply honors `force` (its lockout and
# coverage guards); forward it there, not to other subsystems.
body = {"force": True} if (name == "firewall" and force) else None
handler(None, body)
applied.append(name)
except Exception as exc:
label = SYS_LABELS.get(name, name)
+15 -61
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
@@ -27,9 +28,8 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
from lib.sync import SyncEvent, bus
from lib.wireguard import (
_class_interface_name,
_class_peers,
@@ -122,10 +122,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_saved"})
return {"config_saved": True}
@@ -153,10 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_wireguard_config()
merged = deep_merge(current, body)
_save_wireguard_config(merged)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_patched"})
return {"config_saved": True}
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_wireguard_config()
stamp_applied(cfg_after)
_save_wireguard_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
synced = emit_and_refresh("wireguard", {"action": "config_applied"})
return {
"applied": True,
"synced": sync_result.affected_subsystems,
"synced": synced,
"interfaces": affected,
}
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
except Exception:
pass
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "tunnel_down"})
return {"down": True}
@@ -296,12 +284,7 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
return {"up": True, "interface": ifname}
@@ -328,14 +311,7 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent(
"wireguard",
"config_saved",
{"action": "class_down", "class_key": class_key},
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
return {"down": True, "interface": ifname}
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
_save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "initialized"})
safe = dict(cfg)
safe["interface"] = dict(safe["interface"])
@@ -467,12 +440,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added"
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
peer_out = dict(peers[name])
peer_out.pop("private_key", None)
return peer_out
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name]
_save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
return {"name": name}
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"public_key": "",
}
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_created"})
out = dict(classes[key])
out.pop("private_key", None)
return out
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if field in body:
class_cfg[field] = body[field]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_updated"})
out = dict(classes[key])
out.pop("private_key", None)
return {"key": key, **out}
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
del classes[key]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_deleted"})
return {"key": key}
+28 -16
View File
@@ -18,6 +18,7 @@ from typing import Any
from aiohttp import web
import daemon.collectors # noqa: F401 (registers state collectors)
from daemon.iface import PathLike
from lib.auth import blacklist_expired
from lib.state import _DEFAULT_POLL_INTERVALS
@@ -145,14 +146,18 @@ class Registry:
registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
def refresh_state(subsystems: list[str] | None = None, bump: bool = True) -> None:
"""Refresh the pre-computed state for the given subsystems (or all).
Args:
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
bump: Bump the version counter for each refreshed subsystem.
``refresh_status`` passes ``False`` versions advance on
structural poll diffs and on mutation-triggered refreshes only.
"""
state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS
if bump:
for name in targets:
state_store.bump(name)
try:
@@ -600,22 +605,11 @@ async def refresh_status(_request: web.Request) -> web.Response:
except (json.JSONDecodeError, ValueError):
body = None
subsystems = body.get("subsystems") if body else None
state_store.populate(subsystems)
targets = subsystems or state_store.SUBSYSTEMS
snapshot = {name: state_store.get(name) for name in targets}
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
# Deliberately no version bump — versions advance on structural poll
# diffs and on refresh_state() only.
async def _broadcast_all():
await asyncio.gather(
*[broadcast_versions(name) for name in targets],
return_exceptions=True,
)
task = asyncio.create_task(_broadcast_all())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
refresh_state(subsystems, bump=False)
targets = subsystems or state_store.SUBSYSTEMS
snapshot = {name: state_store.get(name) for name in targets}
return ok(snapshot)
@@ -698,7 +692,7 @@ def main() -> None:
_stop_polling()
# Suppress the default exception handler during teardown so that
# cancelling in-flight tasks does not spew tracebacks on SIGTERM.
prev_handler = loop.exception_handler
prev_handler = loop.get_exception_handler()
loop.set_exception_handler(_teardown_exception_handler)
try:
# Stop accepting new connections (also waits for open sockets,
@@ -747,6 +741,24 @@ def main() -> None:
if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
# Filesystem bootstrap after the import (which must see absent config
# files to adopt live system state on first start): create runtime
# directories and persist the one-shot nginx legacy-format migration.
from lib.bootstrap import bootstrap
bootstrap()
# Reopen group access on the ACME home before the first acme.sh
# collection: a tree left owner-only by a prior run (e.g. a manual
# run as the WebUI user) would otherwise fail every daemon acme.sh
# call until the next issue/renew. Never fatal at startup.
try:
from daemon.handlers.acme import normalize_acme_home
normalize_acme_home()
except Exception:
logger.warning("ACME home normalization failed at startup", exc_info=True)
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
+40 -5
View File
@@ -433,7 +433,9 @@ POST /api/firewall/config
Replace the declarative config. Returns pending changes summary.
**Request Body:** Request body must contain `zones`.
**Request Body:** Request body must contain `zones`. An optional top-level `unmanaged` array (list of interface names) exempts those interfaces from the interface-coverage invariant.
**Errors:** Returns HTTP `400` when the body is malformed (missing/non-dict `zones`, non-list `unmanaged`) or when the config would leave a network-managed interface without zone coverage (the interface-coverage invariant — see `docs/config.md`).
**Response (`data`):**
@@ -452,6 +454,10 @@ POST /api/firewall/config/apply
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**Request Body:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards.
**Errors:** Returns HTTP `409` when the apply is refused by the management-lockout guard (https+ssh stripped from the default zone) or the interface-coverage invariant (a network-managed interface has no zone coverage and is not `unmanaged`). See `docs/config.md`.
**Response (`data`):**
| Field | Type | Description |
@@ -478,6 +484,10 @@ PATCH /api/firewall/config
Deep-merge the provided fields into the existing config. Returns pending changes summary.
**Request Body:** Partial config object; a provided `unmanaged` array replaces the existing one.
**Errors:** Returns HTTP `400` when the merged config is malformed or would leave a network-managed interface without zone coverage (interface-coverage invariant — see `docs/config.md`).
**Response (`data`):**
| Field | Type | Description |
@@ -1976,6 +1986,16 @@ POST /api/status/apply-all
Apply pending changes for all subsystems in dependency order.
**Request Body (optional):**
```json
{ "force": true }
```
`force` is forwarded to the firewall apply only — it overrides the
management-lockout guard and the interface-coverage invariant. Other
subsystems ignore it.
**Response (`data`):**
| Field | Type | Description |
@@ -1983,10 +2003,17 @@ Apply pending changes for all subsystems in dependency order.
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `object` | Map of subsystem label → error message |
The firewall apply runs with `force=false`, so if a firewall interface
would be left without zone coverage (the coverage guard), a
`ConflictError` surfaces in `errors` under `"Firewall"` while the other
subsystems proceed — the desired no-silent-apply behavior.
The endpoint returns `200` even when some subsystems failed — per-subsystem
failures are reported in `errors`, so clients must check `errors` (not just
the HTTP status) before reporting success. Without `force`, the firewall
apply is refused when a network-managed interface has no zone coverage in
the config and is not `unmanaged` (the interface-coverage invariant) or when
the config would strip both https/ssh from the default zone (lockout guard);
the `ConflictError` surfaces in `errors` under `"Firewall"` while the other
subsystems proceed. Pending state comes from
the last state poll (firewall 30s, dnsmasq 10s, nginx 60s, wireguard 10s,
networkd 10s), so an edit saved within the last poll interval may not be
picked up by this call.
---
@@ -2003,6 +2030,14 @@ Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
Notes: pending state comes from the last state poll (firewall 30s,
dnsmasq 10s, nginx 60s, wireguard 10s, networkd 10s), so an edit saved
within the last poll interval is not yet flagged pending and is left in
place. For the firewall, pending is a config-vs-live diff: cancel
restores only the config file, so live firewalld drift made outside the
declarative config (manual `firewall-cmd`) is not reverted and the
firewall may still report pending after a cancel.
**Request Body:** none.
**Response (`data`):**
+3 -3
View File
@@ -337,7 +337,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
```
Client requests / ──→ nginx ──→ Flask (serves index.html)
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
@@ -348,7 +348,7 @@ Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ─
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
```
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries. On the management domain, nginx serves `/static/` directly from `webui/static/` via a `location /static/` alias in the generated server block, so asset requests never reach Flask in production; Flask's static route remains as the dev-mode fallback.
### Component Model
@@ -356,7 +356,7 @@ Each route is a `definePage()` component with reactive state, async data loading
### No Build Step
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers: the management domain's `/static/` assets carry `Cache-Control: no-cache` (browsers revalidate every load; unchanged files return 304 via nginx's built-in ETag), so updates are picked up on the next page load. Dev mode (`VACUUM_WALL_DEV`) uses short TTLs instead.
### WebSocket Data Streaming
+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
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`.
| Field | Type | Required | Description |
|---|---|---|---|
| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. |
| `interfaces` | array | No | Network interfaces assigned to this zone. The config is the source of truth: an omitted key counts as an empty list (apply unassigns the zone's live interfaces). Default: `[]`. |
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. |
| `target` | string | No | Zone target policy. `ACCEPT`, `DROP`, or `REJECT` is fully managed. When the key is **omitted** (the canonical "unmanaged" notation) or normalizes to `default` (e.g. a legacy explicit `"DEFAULT"`), the live value is **preserved** — it is not diffed and never re-set by apply (firewalld cannot set `default` back). |
| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. |
@@ -532,13 +540,18 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
### Applying Firewall Configuration
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. Likewise the `interfaces` entry is only reported for zones whose config explicitly carries the key.
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly.
Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
**Interface-coverage guard.** `POST /firewall/config/apply` also refuses (before any mutation) if applying would leave a network-subsystem-managed interface in **no** firewall zone — traffic (and DHCP) on that segment would be dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Zones whose config omits the `interfaces` key are left hands-off, so their current live interfaces count as coverage, as do the live interfaces of zones that are live but absent from the config. Send `"force": true` to override. The condition is always surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`) and as an advisory in `GET /api/status/pending`.
**Interface-coverage invariant.** Every network-subsystem-managed interface must be covered by a zone in the firewall config — otherwise all traffic (and DHCP) from that segment is dropped. Guarded interfaces are the keys of the network config's `interfaces`, excluding `lo` and `wg*` (vpn zones are managed by the WireGuard sync and `lo` is normally zoneless). Because the config is the source of truth for zone interfaces (an omitted `interfaces` key counts as empty), coverage is computed from the config **alone** via `validate_coverage()` — there is no live-state fallback and no hands-off zones. Interfaces listed in the top-level `unmanaged` key are exempt. The invariant is enforced at two points:
- **Save time**`POST /firewall/config` and `PATCH /firewall/config` reject a config that leaves a managed interface uncovered with `400 Bad Request`, before anything is written.
- **Apply time**`POST /firewall/config/apply` re-checks the (possibly stale) saved config against the current network config and raises `409 Conflict` before any mutation. A conflict here means the network config changed after the firewall config was saved (e.g. a new interface no zone covers).
Send `"force": true` in the request body to override the apply-time check (the UI offers this via the Apply dialog). Live drift — an interface that is covered by the config but not in any **live** zone — is advisory only: it is surfaced as the `uncovered_interfaces` field in firewall state (see `docs/state-model.md`), the Zones-page banner, and an advisory in `GET /api/status/pending`, and is never blocked by the invariant.
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
+74 -5
View File
@@ -677,7 +677,15 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
### `toast(message, type, duration)`
Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default.
Toast behavior:
- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it.
- The auto-dismiss timer pauses while the pointer is over the toast.
- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block.
### `dismissToast(id)`
@@ -947,9 +955,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card.
#### `Card({ header, children })`
#### `Card({ header, children, cls, title })`
Card container with optional header.
Card container with optional header. `cls` appends a class to the outer
`div.card`; `title` sets a tooltip on the outer div.
#### `ConfirmDelete(props)`
@@ -1152,9 +1161,9 @@ ZoneSelect({
| `onChange` | `(zone) => void` callback |
| `placeholder` | Placeholder option text (optional) |
#### `Table({ columns, rows, emptyText, wrapCard, key })`
#### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes.
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
```javascript
Table({
@@ -1337,6 +1346,66 @@ re-apply the current state instead of losing it.
Render the toast notification container. Include in the main render root. See API section above.
## Dirty / pending-edit markers
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
applied to the live system. It consumes the pending state the daemon already
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
#### `PendingDot()`
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
the first cell of a dirty row, or next to a card/section heading.
### Hash subsystems (field-level)
Pending source: `status.pending_diff``[{path, action, old, new}]` where `path`
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
`domains.example.local.cert`).
| Function | Description |
|---|---|
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
| `dirtyInfo(set, path)` | `{dirty, class, title}``class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
parent-like row — never a false negative.
### Firewall (zone + type)
Pending source: `pending``{needs_apply, pending: [{zone, type, ...}]}` where
`type``interfaces|services|target|masquerade|rich_rules|forward_ports`
(zone-level, not field-level).
| Function | Description |
|---|---|
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
### Wiring conventions
- Compute the set **once** per `render()`, after the guard:
`const set = dirtySet(state.<subsystem>.data?.status)` or
`const fw = fwDirty(state.firewall.data?.pending)`.
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
drop `PendingDot({})` into the first cell when `info.dirty`.
- Container elements (tables/sections) whose children are dict keys: pass
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
which leave no row to mark — still surface on the container (WireGuard peers table).
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
## Helpers
| Function | Description |
+1 -1
View File
@@ -70,7 +70,7 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
### Management Interface
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. Static assets under `/static/` are served directly by nginx from `webui/static/` (unauthenticated, the same exposure as the Flask static route) with `Cache-Control: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive `Content-Security-Policy: default-src 'none'`.
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
+11 -4
View File
@@ -27,7 +27,11 @@ return annotation references them.
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset.
(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
@@ -63,9 +67,12 @@ Top-level `FirewallState`:
// (lib/firewall.py get_service_descriptions,
// cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any live zone —
// advisory coverage warning; empty =
// fully covered; NOT counted in pending
// lo/wg*) not in any LIVE zone — a
// live-drift advisory (config may still
// cover them); distinct from the
// config-based interface-coverage
// invariant (docs/config.md); NOT
// counted in pending
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",
+30 -1
View File
@@ -96,6 +96,17 @@ def _run_acme(args: list[str]) -> str:
"--config-home",
acme_home_env,
*args,
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
# runs (whose stdout is captured below) leave a persistent record
# of the raw CA exchange. The log file is passed explicitly (never
# as a bare trailing --log): a valueless trailing --log makes
# acme.sh's arg loop double-shift under dash (the --log branch
# shifts once, then the loop's trailing `shift 1` runs with zero
# positional params) and fails with "shift: can't shift that many"
# (exit 2). The explicit path keeps the same default destination
# ($LE_CONFIG_HOME/acme.sh.log) and can never swallow a real arg.
"--log",
str(Path(acme_home_env) / "acme.sh.log"),
]
try:
@@ -118,12 +129,30 @@ def _run_acme(args: list[str]) -> str:
if result.returncode != 0:
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
raise RuntimeError(
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
f"acme.sh failed with exit code {result.returncode}: "
f"{_summarize_acme_output(output)}"
)
return output
def _summarize_acme_output(output: str) -> str:
"""Reduce raw acme.sh output to a concise, human-readable summary.
acme.sh prints timestamped transcript lines; the failure reason is
in the final lines (e.g. "The retryafter=86400 value is too large
(> 600), will not retry anymore."). Strips per-line timestamps and
the "Please check log file" pointer so the summary stays toast-
sized. The full transcript remains in the log and acme.sh.log.
"""
lines = [line.strip() for line in output.strip().splitlines() if line.strip()]
lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines]
lines = [line for line in lines if not line.startswith("Please check log file")]
if not lines:
return "(no output)"
return "; ".join(lines[-2:])
def set_email(email: str) -> None:
"""Configure the default ACME contact email.
+40
View File
@@ -0,0 +1,40 @@
"""Daemon-startup filesystem bootstrap.
Runs once at daemon startup, after the system-config import and before the
first state collection. Creates the runtime directories subsystems
read/write and persists the one-shot nginx legacy-format migration.
Config *files* are deliberately NOT created here: ``get_config`` reads are
pure and return in-memory defaults, and the system-config import must see
absent files in order to adopt live system state on first start. Files are
materialized on the first ``save_config`` (or by the import itself).
"""
from lib import dnsmasq, firewall, network, nginx, wireguard
from lib.common import ensure_dirs
__all__ = ["bootstrap"]
def bootstrap() -> None:
"""Create runtime directories and persist the one-shot nginx migration.
Idempotent existing directories are left untouched and the nginx
migration only rewrites the on-disk file when it actually changes.
"""
ensure_dirs(
dnsmasq.CONFIG_DIR,
dnsmasq.DATA_DIR,
dnsmasq.FRAGMENTS_DIR,
firewall.CONFIG_DIR,
firewall.DATA_DIR,
network.CONFIG_DIR,
network.DATA_DIR,
nginx.CONFIG_DIR,
nginx.SITES_DIR,
wireguard.CONFIG_PATH.parent,
)
# One-shot legacy-format migration for the nginx config (see
# ``lib.nginx.get_config``). Runs here, at startup, so read paths stay
# side-effect free.
nginx.migrate_config_file()
+19
View File
@@ -120,6 +120,24 @@ def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> Non
out.append({"path": path, "action": "changed", "old": old, "new": new})
def compute_pending(cfg: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]:
"""Return ``(pending_changes, pending_diff)`` from apply bookkeeping keys.
``pending_changes`` is ``True`` when the config was never applied or its
content no longer matches the recorded ``_last_applied_hash``. When
pending and a ``_last_applied_config`` snapshot is recorded, the diff is a
field-level comparison of the snapshot against the current (meta-stripped)
config; otherwise it is empty.
"""
pending = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(cfg)
diff: list[dict[str, Any]] = []
if pending:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
diff = deep_diff(snap, strip_apply_meta(cfg))
return pending, diff
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -301,6 +319,7 @@ __all__ = [
"_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY",
"_hash_password",
"compute_pending",
"config_hash",
"deep_diff",
"deep_merge",
+11 -2
View File
@@ -37,8 +37,12 @@ DEFAULT_CFG: dict[str, Any] = {
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
"""Load current dnsmasq config from JSON state file.
Pure read never writes or creates directories. Returns the in-memory
default when the file is missing; directories and the file are
materialized on the first ``save_config``.
"""
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
@@ -73,6 +77,11 @@ def set_domain(domain: str | None) -> None:
__all__ = [
"CONFIG_DIR",
"CONFIG_PATH",
"DATA_DIR",
"DEFAULT_CFG",
"FRAGMENTS_DIR",
"get_config",
"save_config",
"set_domain",
+61 -9
View File
@@ -7,6 +7,7 @@ All privileged commands are handled by daemon/handlers/firewall.py.
import logging
from collections.abc import Sequence
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -327,9 +328,16 @@ def _ensure_config_file() -> None:
def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
return load_json(CONFIG_FILE)
"""Return the declarative config from ``config/firewall/config.json``.
Pure read never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config`` (or
by the system-config import on first start).
"""
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
def save_config(cfg: dict[str, Any]) -> None:
@@ -370,11 +378,10 @@ def _compute_pending_changes(
Pure function no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
The interfaces diff is only reported for zones whose config explicitly
carries an ``interfaces`` key; zones with the key absent are hands-off
(apply keeps their live interfaces), so diffing them would advertise
changes that never happen. Likewise the target diff is only reported when
the config carries an explicit target that normalizes to something other
The config is the source of truth for zone interfaces: an absent
``interfaces`` key counts as an empty list, so every config zone is
diffed on interfaces. Likewise the target diff is only reported when the
config carries an explicit target that normalizes to something other
than ``default`` an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones.
@@ -387,7 +394,8 @@ def _compute_pending_changes(
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if "interfaces" in zone_cfg:
# The config is the source of truth for zone interfaces: an absent
# key counts as an empty list, so every config zone is diffed.
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
@@ -488,6 +496,49 @@ def _compute_pending_changes(
}
def validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]:
"""Return network-managed interfaces with no firewall zone coverage.
Pure compares the declarative firewall config against the network
config; no live state. A managed interface is covered when it appears in
some zone's ``interfaces`` list (an absent key counts as empty), or is
explicitly declared in the top-level ``unmanaged`` list. ``lo`` and
``wg*`` interfaces are never guarded (VPN zones are managed by the
WireGuard sync; loopback is normally zoneless).
Args:
fw_cfg: Firewall declarative config (``zones`` plus optional
top-level ``unmanaged`` list).
net_cfg: Network config (``interfaces`` mapping).
Returns:
Sorted list of uncovered interface names; empty when the config is
valid.
"""
managed = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
if not managed:
return []
covered: set[str] = set()
for zone_cfg in fw_cfg.get("zones", {}).values():
if isinstance(zone_cfg, dict):
covered.update(
i for i in zone_cfg.get("interfaces", []) if isinstance(i, str)
)
unmanaged_raw = fw_cfg.get("unmanaged", [])
unmanaged = (
{i for i in unmanaged_raw if isinstance(i, str)}
if isinstance(unmanaged_raw, list)
else set()
)
return sorted(
name for name in managed if name not in covered and name not in unmanaged
)
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
"""Compare declarative config against firewalld live state, return diff.
@@ -554,4 +605,5 @@ __all__ = [
"load_backup",
"save_backup",
"save_config",
"validate_coverage",
]
+8 -4
View File
@@ -8,6 +8,7 @@ import contextlib
import ipaddress
import json
import logging
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -78,13 +79,16 @@ __all__ = [
def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json.
Pure read never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config``.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
def save_config(cfg: dict[str, Any]) -> None:
+27 -5
View File
@@ -169,15 +169,17 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
"""Load the current nginx config (pure read, in-memory migration).
Ensures config and sites directories exist, applies migrations for
legacy formats, then returns the config dict.
Never writes or creates directories. Returns the in-memory default when
the file is missing and applies legacy-format migration in memory, so
read paths (state collectors, apply-time checks) stay side-effect free.
The one-shot on-disk migration runs at daemon startup via
``migrate_config_file``.
Returns:
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
@@ -185,9 +187,27 @@ def get_config() -> dict[str, Any]:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
return _migrate_config(raw)
def migrate_config_file() -> bool:
"""Persist the one-shot legacy-format migration, if the file needs it.
Runs at daemon startup so ``get_config`` reads stay pure. Rewrites the
on-disk file only when migration actually changes it.
Returns:
True when the on-disk file was rewritten, False otherwise.
"""
raw = load_json(CONFIG_FILE)
if not raw:
return False
pre = deepcopy(raw)
raw = _migrate_config(raw)
if raw != pre:
save_config(raw)
return raw
return True
return False
def save_config(cfg: dict[str, Any]) -> None:
@@ -396,6 +416,7 @@ def generate_server_conf(
cert_key_path=cert_key_path,
domain_auth=domain_auth,
has_management=has_management,
static_root=str(PROJECT_DIR / "webui" / "static"),
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
@@ -610,6 +631,7 @@ __all__ = [
"get_config",
"get_domains",
"get_management_domains",
"migrate_config_file",
"remove_domain",
"save_config",
"test_config",
+4 -1
View File
@@ -280,15 +280,18 @@ class AcmeState(TypedDict):
"""ACME state (collector: `_collect_acme`).
Attributes:
certs: Certificate list.
certs: Certificate list (empty when collection failed).
email: Registered ACME email.
account: Account status (see AcmeAccount).
status: Collection status; ``error`` is ``None`` on success or
the failure message when cert collection was not possible.
timestamp: ISO-8601 collection time.
"""
certs: list[AcmeCert]
email: str
account: AcmeAccount
status: dict[str, str | None]
timestamp: str
+12 -993
View File
File diff suppressed because it is too large Load Diff
+34 -4
View File
@@ -8,10 +8,18 @@ caused by install.sh or manual edits to system files.
import contextlib
import logging
import re
from copy import deepcopy
from pathlib import Path
from typing import Any
from lib.common import load_json, run, save_json
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
load_json,
run,
save_json,
stamp_applied,
)
from lib.firewall import _live_target_to_config, _parse_all_zones_output
logger = logging.getLogger(__name__)
@@ -53,6 +61,24 @@ def import_all() -> list[str]:
return updated
def _carry_apply_meta(cfg: dict[str, Any], existing: dict[str, Any]) -> None:
"""Preserve apply bookkeeping when adopting live system state.
Imported content replaces the declarative config but must not destroy
the applied-state baseline. When *existing* carries apply meta keys,
they are copied over so pending-change detection and cancel-all keep
working against the last-applied baseline. When no baseline exists
(first import), *cfg* is stamped as applied the imported content is
exactly the state the system is currently running.
"""
if _APPLY_HASH_KEY in existing or _LAST_APPLIED_CONFIG_KEY in existing:
for key in (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY):
if key in existing:
cfg[key] = deepcopy(existing[key])
else:
stamp_applied(cfg)
# ------------------------------------------------------------------
# Dnsmasq
# ------------------------------------------------------------------
@@ -86,12 +112,13 @@ def import_dnsmasq() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping dnsmasq: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
@@ -247,12 +274,13 @@ def import_wireguard() -> bool:
return False
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
existing: dict[str, Any] = {}
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping wireguard: config already matches")
return False
_carry_apply_meta(cfg, existing)
save_json(cfg_path, cfg)
peer_count = len(cfg.get("peers", {}))
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
@@ -941,7 +969,9 @@ def import_firewall() -> bool:
logger.debug("Skipping firewall: no zones with interfaces")
return False
save_json(cfg_path, {"zones": zone_configs})
# Only reached when the config file is absent: the imported zones are
# exactly what firewalld is running, so stamp them as the applied state.
save_json(cfg_path, stamp_applied({"zones": zone_configs}))
logger.info(
"Imported firewall config: zones=%s",
", ".join(zone_configs.keys()),
+9 -4
View File
@@ -370,7 +370,7 @@ def status() -> dict[str, Any]:
if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []}
continue
class_status = _parse_wg_show_output(res.stdout.strip())
class_status = parse_wg_show_output(res.stdout.strip())
result["classes"][class_key] = class_status
if class_status["up"]:
result["up"] = True
@@ -382,7 +382,7 @@ def status() -> dict[str, Any]:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = _parse_wg_show_output(res.stdout.strip())
parsed = parse_wg_show_output(res.stdout.strip())
result["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", [])
@@ -392,8 +392,12 @@ def status() -> dict[str, Any]:
return result
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict."""
def parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict.
Returns ``{"up", "interface", "peers"}`` where *interface* carries
``public_key``, ``listen_port`` and (when present) ``fwmark``.
"""
result: dict[str, Any] = {
"up": False,
"interface": {},
@@ -742,6 +746,7 @@ __all__ = [
"get_peer_status",
"get_peers",
"initialize",
"parse_wg_show_output",
"remove_peer",
"save_config",
"set_listen_port",
+35
View File
@@ -144,6 +144,32 @@ fi
# Shared group: use the WebUI user's primary group
USER_GROUP=$(id -gn "$USER_NAME")
# Some appliance images ship with top-level system directories (and sometimes
# everything under them) owned by a regular user. This trips systemd-tmpfiles'
# "unsafe path transition" check and lets that user modify system paths.
# Repair the top level here; warn with a full-repair command if deeper
# mis-ownership is detected (depth-1 entries of /etc /usr /var /boot are
# always root-owned on Debian, so this check cannot false-positive).
_sys_dirs=(/ /bin /boot /etc /home /media /mnt /opt /root /sbin /srv /usr /var /var/lib /var/log)
_misowned=()
for _d in "${_sys_dirs[@]}"; do
[[ -e "$_d" ]] || continue
[[ "$(stat -c '%U' "$_d" 2>/dev/null)" == "root" ]] || _misowned+=("$_d")
done
if [[ ${#_misowned[@]} -gt 0 ]]; then
warn "System directories not owned by root: ${_misowned[*]}"
warn "Chowning to root:root (image shipped with mis-owned system paths)."
chown root:root "${_misowned[@]}"
_deep_count=$(find /etc /usr /var /boot -maxdepth 1 ! -user root 2>/dev/null | wc -l)
if [[ "$_deep_count" -gt 0 ]]; then
warn "Deeper mis-ownership detected ($_deep_count entries at depth 1)."
warn "Run a full repair, then re-run this installer:"
warn " sudo find / -xdev -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -path /run -prune -o -path /tmp -prune -o -path /home/$USER_NAME -prune -o -user $USER_NAME -print0 | xargs -0 -r chown root:root"
else
log "Repaired top-level system directory ownership."
fi
fi
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
@@ -212,6 +238,15 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
# nginx workers (www-data) serve webui/static directly from disk for the
# management domain — ensure read access regardless of checkout umask.
chmod -R a+rX "${PROJECT_DIR}/webui/static"
# ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work.
_d="${PROJECT_DIR}"
while [[ "$d" != "/" && -n "$d" ]]; do
chmod a+x "$d" 2>/dev/null || true
d="$(dirname "$d")"
done
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
# The top-level .git (directory or worktree pointer file) is left untouched so
# the repo owner's git isn't tripped by git's dubious-ownership check.
+10
View File
@@ -53,6 +53,16 @@ server {
{% endif %}
{% for ppath, pcfg in paths.items() %}
{% if pcfg.is_management and ppath == '/' %}
# SPA static assets — served from disk, no Flask round-trip.
# no-cache: browsers revalidate every load; unchanged files are 304s.
location /static/ {
alias {{ static_root }}/;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'" always;
}
{% endif %}
{% if pcfg.is_websocket %}
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
location {{ ppath }} {
+8
View File
@@ -45,6 +45,14 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
# Sysctl
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
# ACME home permissions (acme.sh chmods its tree to owner-only modes:
# 700 on the config home, 600 on keys/confs — group access must be
# reopened so the shared two-user model can read the tree). Files only:
# the setgid directories (2775) already grant group rwx, and chmodding
# them would trip the daemon unit's RestrictSUIDSGID seccomp filter.
# The trailing * spans the file argument list.
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chmod g+rwX {{ ACME_HOME }}/*
# Misc
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
+9 -2
View File
@@ -3,8 +3,15 @@ Description=Vacuum Wall ACME Certificate Renewal
[Service]
Type=oneshot
User={{ USER_NAME }}
# Run as the daemon user, not the WebUI user: it owns the project tree
# (and the ACME home) in production, and acme.sh chmods its config home
# to 700 and its keys/confs to 600 on every run. Running as the WebUI
# user left the tree unreadable to the daemon (and vice versa) whenever
# the two users' runs interleaved.
User={{ USER_DAEMON_NAME }}
WorkingDirectory={{ PROJECT_DIR }}
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }}
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
# --log: persistent on-disk transcript of the raw CA exchange (journald
# captures stdout regardless; the file survives journal retention).
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }} --log
+5 -1
View File
@@ -2,8 +2,12 @@
Description=Vacuum Wall ACME Certificate Renewal Timer
[Timer]
# Daily only: ZeroSSL backs off a failed validation for 24h per domain
# (Retry-After: 86400). With two runs a day every attempt landed inside
# the previous attempt's backoff window, re-arming it — a permanent
# renewal lockout. Attempts >24h apart are required for the backoff to
# ever expire (acme.sh discussion #6419).
OnCalendar=*-*-* 00:00:00
OnCalendar=*-*-* 12:00:00
Persistent=true
RandomizedDelaySec=300
+6
View File
@@ -16,6 +16,12 @@ TimeoutStopSec=15
Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
# acme.sh routes its _info/_err lines through logger(1) -> journald when
# SYS_LOG is set (default: off). This journals manual issue/renew runs
# in real time under this unit, whose subprocess stdout is otherwise
# captured by the daemon and never seen by the journal.
# Levels: 3=error, 6=info, 7=debug.
Environment=SYS_LOG=6
Environment=HOME={{ PROJECT_DIR }}
# Runtime directories created before namespace setup. ProtectSystem=strict
+34 -1
View File
@@ -5,7 +5,7 @@
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
*/
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
@@ -235,5 +235,38 @@ test('buildRows row VNodes have correct tag', () => {
}
});
// === applyResultToasts ===
// apply-all returns 200 with { applied, errors } even when subsystems
// failed — resp.ok alone is not a success signal; errors must win.
test('applyResultToasts: errors suppress the success toast', () => {
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
assertEq(t.success, null, 'no success toast when errors exist');
assertIncludes(t.error, 'Firewall — refused');
});
test('applyResultToasts: success toast when applied and no errors', () => {
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, 'All changes applied');
});
test('applyResultToasts: no toast when nothing applied and no errors', () => {
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, null);
});
test('applyResultToasts: multiple errors are joined', () => {
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
assertIncludes(t.error, 'Firewall — a');
assertIncludes(t.error, 'Nginx — b');
});
test('applyResultToasts: null payload is safe', () => {
const t = applyResultToasts(null, 'ok');
assertEq(t.error, null);
assertEq(t.success, null);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests for hoover/dirty.js pending-edit marker matching.
*
* dirty.js has no imports DOM-free at import, so the tests run under
* plain node (same pattern as test-model-set.js).
*
* Run with `node tests/test-dirty.js`.
*/
import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── dirtySet ────────────────────────────────────────────────── */
test('dirtySet collects pending paths from pending_diff', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[0].start', action: 'changed' },
{ path: 'dns.domain', action: 'added' },
]});
assert(set.has('dhcp.ranges[0].start'), 'first path collected');
assert(set.has('dns.domain'), 'second path collected');
assertEq(set.size, 2, 'exactly two paths');
});
test('dirtySet skips diff entries without a path', () => {
const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] });
assertEq(set.size, 1, 'only well-formed entries');
assert(set.has('a.b'), 'valid path collected');
});
test('dirtySet is empty when pending_diff is absent', () => {
assertEq(dirtySet(null).size, 0, 'null status');
assertEq(dirtySet({}).size, 0, 'empty status');
assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff');
});
/* ── never-applied sentinel ──────────────────────────────────── */
test('dirtySet marks everything dirty when saved but never applied', () => {
const set = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(set.size, 1, 'sentinel only');
assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty');
assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty');
assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip');
});
test('dirtySet has no sentinel when there is no pending state', () => {
const set = dirtySet({ pending_changes: false, pending_diff: [] });
assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending');
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean');
});
test('dirtySet has no sentinel when a real diff exists', () => {
const set = dirtySet({
pending_changes: true,
pending_diff: [{ path: 'dns.domain', action: 'changed' }],
});
assert(isDirty(set, 'dns.domain'), 'matching path is dirty');
assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean');
assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel');
});
/* ── line matching ───────────────────────────────────────────── */
test('isDirty matches an exact pending leaf', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty');
});
test('a pending list marks every indexed row (ancestor of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] });
for (const i of [0, 1, 12]) {
assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`);
assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`);
}
});
test('a pending row field marks the list (descendant of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] });
assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty');
assert(isDirty(set, 'dhcp'), 'the top-level container is dirty');
});
test('unrelated paths do not match', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges'), 'different root');
});
test('index brackets do not prefix-match across digits', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12');
assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10');
assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty');
});
test('plain keys do not prefix-match similar names', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0');
assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2');
});
test('isDirty is false for an empty or missing set', () => {
assert(!isDirty(new Set(), 'a.b'), 'empty set');
assert(!isDirty(null, 'a.b'), 'null set');
assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending');
});
test('isDirty tolerates an empty path', () => {
const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] });
assert(!isDirty(set, ''), 'empty element path is not dirty');
assert(!isDirty(set, null), 'null element path is not dirty');
});
/* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */
test('dirtyTitle lists all matching pending paths sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[1].start', action: 'changed' },
{ path: 'dhcp.ranges[0].end', action: 'changed' },
{ path: 'dns.domain', action: 'changed' },
]});
assertEq(
dirtyTitle(set, 'dhcp.ranges'),
'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start',
'both rows listed, sorted, unrelated path excluded',
);
});
test('dirtyTitle is empty when the element is clean', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element');
});
test('dirtyInfo returns the full marker object', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
const hit = dirtyInfo(set, 'dns.domain');
assertEq(hit.dirty, true, 'dirty flag');
assertEq(hit.class, 'config-dirty', 'class');
assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip');
const miss = dirtyInfo(set, 'dhcp.ranges');
assertEq(miss.dirty, false, 'clean flag');
assertEq(miss.class, '', 'clean class');
assertEq(miss.title, '', 'clean title');
});
/* ── orphanInfo (removed dict keys) ──────────────────────────── */
test('orphanInfo flags a removed peer with no live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']);
assertEq(info.dirty, true, 'orphan is dirty');
assertEq(info.class, 'config-dirty', 'orphan class');
assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip');
});
test('orphanInfo is clean when the pending path still has a live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan');
});
test('orphanInfo flags a removed peer when no peers remain', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands');
});
test('orphanInfo ignores pending paths outside the root', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root');
});
test('orphanInfo is clean when the root itself is pending', () => {
// A whole-dict `peers` change marks every child row instead; the
// container-level marker would be redundant.
const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan');
assert(isDirty(set, 'peers.p1'), 'but the rows are still marked');
});
test('orphanInfo is clean for an empty set or the never-applied sentinel', () => {
assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set');
const sentinel = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it');
});
test('orphanInfo lists multiple orphans sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'peers.b', action: 'removed' },
{ path: 'peers.a', action: 'removed' },
{ path: 'peers.c.field', action: 'changed' },
]});
const info = orphanInfo(set, 'peers', ['peers.c']);
assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted');
});
/* ── firewall zone + type granularity ────────────────────────── */
test('fwDirty builds a zone-to-types map', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'services' },
{ zone: 'public', type: 'rich_rules' },
{ zone: 'dmz', type: 'interfaces' },
{ zone: null },
{ zone: 'lan' },
],
});
assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)');
assert(m.get('public').has('services'), 'public services');
assert(m.get('public').has('rich_rules'), 'public rich_rules');
assert(m.get('dmz').has('interfaces'), 'dmz interfaces');
assert(m.get('lan').size === 0, 'typeless zone has an empty type set');
});
test('fwIsDirty by zone and by zone+type', () => {
const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] });
assert(fwIsDirty(m, 'public'), 'zone-only match');
assert(fwIsDirty(m, 'public', 'services'), 'zone+type match');
assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type');
assert(!fwIsDirty(m, 'dmz'), 'unknown zone');
assert(!fwIsDirty(new Map(), 'public'), 'empty map');
});
test('fwInfo and fwTitle carry the pending types', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'rich_rules' },
{ zone: 'public', type: 'services' },
],
});
const zone = fwInfo(m, 'public');
assertEq(zone.dirty, true, 'zone dirty');
assertEq(zone.class, 'config-dirty', 'zone class');
assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types');
const typed = fwInfo(m, 'public', 'services');
assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type');
assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean');
assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone');
});
/* ── Runner ──────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+19
View File
@@ -58,6 +58,25 @@ class TestRunAcme:
assert cmd[0] == "/usr/local/bin/acme.sh"
assert "sudo" not in cmd
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_log_flag_is_last(self, mock_run, mock_find):
# --log <file> must trail the subcommand args: acme.sh would
# otherwise consume the first subcommand arg as its file argument.
# The explicit file path (not a bare trailing --log) is required
# because a valueless trailing --log makes acme.sh's arg loop
# double-shift under dash and fail with "shift: can't shift that
# many".
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
acme._run_acme(["--issue", "-d", "example.com"])
cmd = mock_run.call_args[0][0]
assert cmd.count("--log") == 1
assert cmd[-2] == "--log"
assert cmd[-1].endswith("acme.sh.log")
assert cmd.index("--issue") < cmd.index("--log")
assert "example.com" in cmd
class TestParseListOutput:
def test_parses_single_entry(self):
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
import pytest
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
"""Point every bootstrap-referenced path into a throwaway tree."""
cfg = tmp_path / "config"
data = tmp_path / "data"
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
return tmp_path
def test_creates_runtime_dirs(sandbox):
bootstrap.bootstrap()
assert dnsmasq.FRAGMENTS_DIR.is_dir()
assert firewall.DATA_DIR.is_dir()
assert network.DATA_DIR.is_dir()
assert nginx.SITES_DIR.is_dir()
assert wireguard.CONFIG_PATH.parent.is_dir()
def test_does_not_create_config_files(sandbox):
# Config files are left for system-import (first start) or the first
# save_config — bootstrap must not pre-empt either.
bootstrap.bootstrap()
assert not nginx.CONFIG_FILE.exists()
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
assert not (network.CONFIG_DIR / "config.json").exists()
assert not (firewall.CONFIG_DIR / "config.json").exists()
assert not wireguard.CONFIG_PATH.exists()
def test_persists_nginx_migration(sandbox):
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
bootstrap.bootstrap()
on_disk = nginx.get_config()
assert on_disk["backends"]["webui"]["_migrated"] is True
raw = nginx.CONFIG_FILE.read_text()
assert '"_migrated": true' in raw or '"_migrated":True' in raw
def test_idempotent(sandbox):
nginx.save_config({"domains": {}})
bootstrap.bootstrap()
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
bootstrap.bootstrap()
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
+51
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
compute_pending,
config_hash,
deep_diff,
load_json,
@@ -78,6 +79,56 @@ class TestDeepDiff:
assert not any(p.startswith("z.ranges[0].n") for p in paths)
class TestComputePending:
def test_hash_match_no_pending(self):
cfg = {"a": 1}
stamp_applied(cfg)
pending, diff = compute_pending(cfg)
assert pending is False
assert diff == []
def test_never_applied_pending_no_snapshot(self):
pending, diff = compute_pending({"a": 1})
assert pending is True
assert diff == []
def test_never_applied_pending_with_foreign_snapshot(self):
# A recorded snapshot that does not match the current hash is still
# used for the diff.
cfg = {"a": 2, _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
def test_hash_mismatch_with_snapshot_diffs(self):
applied = {"zones": {"lan": {"services": ["http"]}}}
stamped = dict(applied)
stamp_applied(stamped)
drifted = {"zones": {"lan": {"services": ["http", "ssh"]}}}
drifted[_LAST_APPLIED_CONFIG_KEY] = applied
drifted[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
pending, diff = compute_pending(drifted)
assert pending is True
paths = {d["path"] for d in diff}
assert "zones.lan.services" in paths
def test_hash_mismatch_snapshot_not_dict(self):
cfg = {"a": 1, _LAST_APPLIED_CONFIG_KEY: "not-a-dict"}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == []
def test_meta_keys_excluded_from_diff(self):
cfg = {"a": 1}
stamp_applied(cfg)
cfg["a"] = 2 # drift
pending, diff = compute_pending(cfg)
assert pending is True
assert not any(
p.startswith(("_last_applied",)) for d in diff for p in [d["path"]]
)
class TestDashboardFallback:
def test_hash_subsystem_unchanged_generic(self):
# Guards that a pending status without a snapshot still yields a
+310 -83
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from daemon.handlers import common as daemoncommon
from daemon.handlers import firewall as daemonfirewall
from daemon.server import ConflictError, NotFoundError
from lib import firewall
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
so their interfaces diff must not be reported; other field drift is."""
"""The config is the source of truth for zone interfaces: an absent
'interfaces' key counts as an empty list, so every config zone is
diffed on interfaces (no hands-off zones)."""
def test_services_drift_reported_without_interfaces_key(self):
def test_services_and_interfaces_drift_reported_without_interfaces_key(self):
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
types = {c["type"] for c in result["pending"]}
assert "services" in types
assert "interfaces" not in types
# Absent key counts as an empty list: live eth0 is a pending removal.
assert "interfaces" in types
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
# Config in sync on everything except a missing interfaces key: the
# zone's live interfaces are intentionally left alone by apply.
def test_absent_key_zone_in_sync_live_reports_nothing(self):
# Config lacks the interfaces key and the live zone has no
# interfaces either — absent key equals the empty live set.
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
result = firewall._compute_pending_changes(cfg, {"public": live})
assert result["pending"] == []
assert result["needs_apply"] is False
@@ -457,6 +459,51 @@ class TestTargetDriftSemantics:
# ---------------------------------------------------------------------------
class TestValidateCoverage:
def test_all_covered(self):
fw = {"zones": {"public": {"interfaces": ["eth0"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_uncovered_reported_sorted(self):
fw = {"zones": {"public": {"interfaces": ["eth1"]}}}
net = {"interfaces": {"eth5": {}, "eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0", "eth5"]
def test_unmanaged_exempts(self):
fw = {
"zones": {"public": {"interfaces": ["eth1"]}},
"unmanaged": ["eth0"],
}
net = {"interfaces": {"eth0": {}, "eth1": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_lo_and_wg_exempt(self):
fw = {"zones": {}}
net = {"interfaces": {"lo": {}, "wg0": {}, "wg-full": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_absent_key_counts_as_empty(self):
# A zone without an 'interfaces' key covers nothing.
fw = {"zones": {"public": {"services": ["http"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_empty_network_config(self):
assert firewall.validate_coverage({"zones": {}}, {"interfaces": {}}) == []
assert firewall.validate_coverage({"zones": {}}, {}) == []
def test_non_dict_zone_and_non_list_unmanaged_ignored(self):
fw = {"zones": {"public": "oops"}, "unmanaged": "eth0"}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_non_string_entries_ignored(self):
fw = {"zones": {"public": {"interfaces": [None, 7]}}, "unmanaged": [None]}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
class TestGetZoneInfo:
def test_parses_zone_info(self):
result = firewall._parse_zone_output(
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
# ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface
# in no zone (clients lose connectivity/DHCP) unless forced.
# Coverage invariant: the config must cover every network-managed
# interface (or declare it unmanaged). Pure config check — the config is
# the source of truth for zone interfaces (absent key = empty), so there
# is no live-state comparison and no hands-off zones.
# ---------------------------------------------------------------------------
@@ -849,7 +898,7 @@ def _apply_with(
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg),
@@ -860,21 +909,88 @@ def _apply_with(
return result, mock_run
class TestDaemonInterfaceCoverageGuard:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
class TestDaemonCoverageInvariant:
def test_conflict_when_network_iface_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run") as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
msg = str(exc.value)
assert "eth0" in msg
assert "unmanaged" in msg
assert "force" in msg
mock_backup.assert_not_called()
# Pure config check: the guard performs no live-state reads at all.
mock_run.assert_not_called()
def test_unmanaged_exempts_iface(self):
cfg = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front.
assert mock_run.call_args_list[0].args[0] == [
cmds = [c.args[0] for c in mock_run.call_args_list]
# Live eth0 is removed, config eth1 added exactly once (permanent).
assert [
"firewall-cmd",
"--get-active-zones",
]
# Hands off: no interface mutation commands for the absent-key zone.
for c in mock_run.call_args_list:
for arg in c.args[0]:
assert not arg.startswith("--remove-interface=")
assert not arg.startswith("--add-interface=")
"--zone=public",
"--remove-interface=eth0",
"--permanent",
] in cmds
assert (
cmds.count(
["firewall-cmd", "--zone=public", "--add-interface=eth1", "--permanent"]
)
== 1
)
def test_absent_key_zone_counts_as_empty(self):
# No hands-off zones: a zone without an 'interfaces' key covers
# nothing, so a managed interface left out of every zone blocks.
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run"),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "eth0" in str(exc.value)
def test_live_only_zone_does_not_count_as_covered(self):
# eth1 is held by 'guest' live but the config (the source of
# truth) does not cover it — apply is blocked regardless of live.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth1": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\nguest\n eth1\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError),
):
daemonfirewall._config_apply()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
)
def test_conflict_when_network_iface_goes_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
class TestDaemonSaveCoverageValidation:
"""The coverage invariant is enforced at save time too (POST/PATCH
/firewall/config), so bad configs are rejected before they are written."""
def test_save_blocks_uncovered(self):
body = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall._config_apply()
daemonfirewall.save_config_handler(None, body)
assert "eth0" in str(exc.value)
assert "force" in str(exc.value)
assert "unmanaged" in str(exc.value)
mock_save.assert_not_called()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_live_only_zone_interfaces_count_as_covered(self):
# eth1 is held by 'guest', which is live but absent from the config —
# apply never touches it, so eth1 counts as covered.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
assert result["applied_zones"] == ["public"]
def test_coverage_guard_conflict_writes_no_backup(self):
# Guard conflict must be side-effect free, like the lockout conflict.
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
def test_save_allows_unmanaged(self):
body = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
daemonfirewall._config_apply()
mock_backup.assert_not_called()
# Only the guard's live-zone read ran — no mutation commands at all.
assert [c.args[0] for c in mock_run.call_args_list] == [
["firewall-cmd", "--get-active-zones"]
]
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.save_config_handler(None, body)
assert result == {"config_saved": True}
mock_save.assert_called_once()
def test_save_rejects_non_list_unmanaged(self):
body = {"zones": {"public": {"interfaces": ["eth0"]}}, "unmanaged": "eth0"}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
pytest.raises(ValueError) as exc,
):
daemonfirewall.save_config_handler(None, body)
assert "unmanaged" in str(exc.value)
def test_patch_blocks_merge_that_uncovers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"public": {"interfaces": ["eth1"]}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall.patch_config(None, body)
assert "eth0" in str(exc.value)
mock_save.assert_not_called()
def test_patch_allows_merge_that_covers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"internal": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.patch_config(None, body)
assert result == {"config_saved": True}
saved = mock_save.call_args[0][0]
assert set(saved["zones"]) == {"public", "internal"}
# ---------------------------------------------------------------------------
@@ -957,8 +1093,8 @@ class TestDaemonCreateZone:
with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body)
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
) as mock_backup,
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"),
):
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG),
@@ -1099,6 +1235,97 @@ class TestDaemonConfigApplyStamp:
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
class TestDaemonMutatorBaselineStamp:
"""Per-zone mutations apply to live firewalld immediately and must
re-stamp the applied baseline, so cancel-all reverts to the post-mutation
state instead of an older snapshot (regression: stale install-era
snapshot resurrected a phantom 'remove interface' pending change).
"""
ZONES_OUT = "public\ninternal"
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_interfaces(
None, {"zone": "internal", "interfaces": ["eth1"]}
)
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["interfaces"] == ["eth1"]
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["interfaces"] == [
"eth1"
]
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
def test_set_zone_services_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with (
patch.object(
daemonfirewall, "_parse_zone_output", return_value={"services": []}
),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_services(
None, {"zone": "internal", "services": ["ssh"]}
)
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["services"] == ["ssh"]
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["services"] == [
"ssh"
]
@patch("daemon.handlers.firewall._reload")
@patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"internal": {}}},
)
@patch("daemon.handlers.firewall.run")
def test_set_masquerade_syncs_config_and_stamps(
self, mock_run, mock_cfg, mock_reload
):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
saved = mock_save.call_args[0][0]
assert saved["zones"]["internal"]["masquerade"] is True
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
@patch("daemon.handlers.firewall._reload")
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
@patch("daemon.handlers.firewall.run")
def test_set_masquerade_no_config_entry_skips_write(
self, mock_run, mock_cfg, mock_reload
):
"""A zone absent from the config must not gain a bare entry — that
would manufacture spurious service diffs on the next poll."""
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
mock_save.assert_not_called()
class TestDaemonGetConfigEndpoint:
def test_strips_apply_meta(self):
with patch.object(
@@ -1117,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
"daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
)
@patch("daemon.handlers.firewall.bus")
@patch("daemon.handlers.common.bus")
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
with patch("daemon.handlers.firewall.refresh_state"):
with patch("daemon.handlers.common.refresh_state"):
daemonfirewall.config_apply(None, None)
mock_apply.assert_called_once_with(force=False)
mock_apply.reset_mock()
+72
View File
@@ -1,6 +1,7 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import inspect
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -1303,3 +1304,74 @@ class TestGetRenewStatus:
assert status["domain"] == "example.com"
assert status["status"] == "completed"
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
class TestNormalizeAcmeHome:
def test_normalize_invokes_sudo_chmod_on_files(self, tmp_path):
f1 = tmp_path / "account.conf"
f1.write_text("x")
(tmp_path / "sub").mkdir()
f2 = tmp_path / "sub" / "dom.key"
f2.write_text("x")
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch(
"lib.common.run_proc",
return_value=MagicMock(returncode=0, stderr=""),
) as mock_proc,
):
acme_mod.normalize_acme_home()
args = mock_proc.call_args.args[0]
assert args[:2] == ["chmod", "g+rwX"]
assert set(args[2:]) == {str(f1), str(f2)}
mock_proc.assert_called_once()
def test_normalize_empty_tree_skips_sudo(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch("lib.common.run_proc") as mock_proc,
):
acme_mod.normalize_acme_home()
mock_proc.assert_not_called()
def test_normalize_failure_does_not_raise(self, tmp_path):
(tmp_path / "a.conf").write_text("x")
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
patch(
"lib.common.run_proc",
return_value=MagicMock(returncode=1, stderr="denied"),
),
patch.object(acme_mod, "logger"),
):
acme_mod.normalize_acme_home()
def test_preflight_normalizes_before_run(self):
calls = []
with (
patch.object(
acme_mod,
"normalize_acme_home",
side_effect=lambda: calls.append("normalize"),
),
patch.object(
acme_mod,
"_run_acme",
side_effect=lambda args: calls.append("run:" + " ".join(args)) or "ok",
),
):
out = acme_mod._run_acme_preflight(["--list", "--listraw"])
assert calls == ["normalize", "run:--list --listraw"]
assert out == "ok"
class TestPreflightWiring:
def test_issue_uses_preflight(self):
source = inspect.getsource(acme_mod._run_issue)
assert "_run_acme_preflight" in source
assert "normalize_acme_home" in source
def test_renew_uses_preflight(self):
source = inspect.getsource(acme_mod._run_renew)
assert "_run_acme_preflight" in source
assert "normalize_acme_home" in source
+23 -5
View File
@@ -15,18 +15,36 @@ from daemon.handlers.network import (
save_interface,
set_sysctl,
)
from lib import dnsmasq as _dm
from lib import firewall as _fw
from lib import network as _net
@pytest.fixture
def tmp_network(tmp_path):
orig_config = _net.CONFIG_FILE
orig_data = _net.DATA_DIR
# Handler endpoints emit "networkd" sync events; the subscribers
# (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq
# configs, and apply_all re-stamps the dnsmasq config. Point all of
# those paths at tmp so tests never touch the real config files.
orig_net = (_net.CONFIG_FILE, _net.DATA_DIR)
orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR)
orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE)
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
_net.DATA_DIR = tmp_path / "data" / "networkd"
_dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq"
_dm.DATA_DIR = tmp_path / "data" / "dnsmasq"
_dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json"
_dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments"
_dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_dm.DATA_DIR.mkdir(parents=True, exist_ok=True)
_dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
_fw.CONFIG_DIR = tmp_path / "config" / "firewall"
_fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json"
_fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
_net.CONFIG_FILE = orig_config
_net.DATA_DIR = orig_data
_net.CONFIG_FILE, _net.DATA_DIR = orig_net
_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm
_fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw
# =================================================================
@@ -363,7 +381,7 @@ class TestInferEndpoints:
class TestSetSysctl:
def test_set_sysctl_success(self):
def test_set_sysctl_success(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch.object(Path, "read_text", return_value="1"),
+3 -2
View File
@@ -29,10 +29,11 @@ class TestGetConfig:
assert isinstance(cfg, dict)
assert "interfaces" in cfg
def test_creates_config_file(self, tmp_network):
def test_missing_file_returns_default_without_writing(self, tmp_network):
# Pure read: get_config never materializes the file.
cfg = _net.get_config()
assert _net.CONFIG_FILE.exists()
assert cfg["interfaces"] == {}
assert not _net.CONFIG_FILE.exists()
class TestSaveConfig:
+12 -10
View File
@@ -279,18 +279,18 @@ class TestStateParserDedup:
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
def test_state_uses_network_parser(self):
"""The networkd collector in state.py should import from lib.network."""
import lib.state as _state
"""The networkd collector should import from lib.network."""
import daemon.collectors.networkd as _collector
source = Path(_state.__file__).read_text()
assert "from lib.network import parse_networkctl_status" in source
source = Path(_collector.__file__).read_text()
assert "from lib.network import" in source
assert "parse_networkctl_status" in source
def test_networkd_collector_returns_correct_format(self):
"""_collect_networkd should return interfaces dict + timestamp."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run") as mock_run:
with patch("daemon.collectors.networkd.run") as mock_run:
mock_run.return_value = json.dumps(
{
"Interfaces": [
@@ -320,7 +320,7 @@ class TestStateParserDedup:
]
}
)
result = _state._collect_networkd()
result = _collector._collect_networkd()
assert "interfaces" in result
assert "timestamp" in result
@@ -329,10 +329,12 @@ class TestStateParserDedup:
def test_networkd_collector_handles_failure(self):
"""_collect_networkd returns empty interfaces on error."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
result = _state._collect_networkd()
with patch(
"daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
):
result = _collector._collect_networkd()
assert result["interfaces"] == {}
assert "timestamp" in result
+81 -1
View File
@@ -55,6 +55,44 @@ class TestGetConfig:
assert "ssl" in cfg
assert cfg["domains"] == {}
def test_read_does_not_rewrite_unchanged_file(self, temp_data_dir):
"""get_config() must not re-save a file that needs no migration."""
nginx.save_config(
{
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {"app.example.com": {"backend": "webui"}},
"ssl": {"protocols": "TLSv1.3"},
}
)
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config()
assert cfg["domains"] == {"app.example.com": {"backend": "webui"}}
# No churn: reading a current-format config leaves the file alone.
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
"""get_config() is pure: migration is applied in memory, file untouched."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config()
# Migration added the builtin webui backend (in memory only).
assert cfg["backends"]["webui"]["_migrated"] is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_migrate_config_file_persists_legacy(self, temp_data_dir):
"""migrate_config_file() rewrites the file when migration changes it."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
assert nginx.migrate_config_file() is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
# Idempotent: a second run is a no-op.
assert nginx.migrate_config_file() is False
def test_migrate_config_file_noop_when_missing(self, temp_data_dir):
assert not nginx.CONFIG_FILE.exists()
assert nginx.migrate_config_file() is False
assert not nginx.CONFIG_FILE.exists()
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
@@ -266,9 +304,51 @@ class TestGenerateServerConf:
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9090;" in out
assert "add_header X-Content-Type-Options" not in out
# Server-level security headers come from Flask, not nginx
assert "Strict-Transport-Security" not in out
assert "Referrer-Policy" not in out
assert "wall_mgmt_access.log" in out
def test_management_static_location(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
static_root = str(nginx.PROJECT_DIR / "webui" / "static")
assert "location /static/ {" in out
assert f"alias {static_root}/;" in out
assert 'add_header Cache-Control "no-cache" always;' in out
assert "add_header X-Content-Type-Options nosniff always;" in out
assert (
"add_header Content-Security-Policy \"default-src 'none'\" always;" in out
)
def test_static_location_only_for_management_root(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
}
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "location /static/" not in out
def test_websocket_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
+26 -15
View File
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
import json
from unittest.mock import Mock, patch
import lib.state
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
import daemon.collectors.networkd
import daemon.collectors.nginx
import daemon.collectors.system
import daemon.collectors.wireguard
from lib import schema
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
class TestCollectorShapesMatchSchema:
def test_firewall_state(self):
with (
patch.object(lib.state, "run") as mock_run,
patch.object(daemon.collectors.firewall, "run") as mock_run,
patch.object(
lib.state,
daemon.collectors.firewall,
"_network_get_config",
return_value={
"interfaces": {
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
return ""
mock_run.side_effect = run_side
result = lib.state._collect_firewall()
result = daemon.collectors.firewall._collect_firewall()
assert not _missing(schema.FirewallState.__required_keys__, result)
for iface in result["interfaces"]:
@@ -74,37 +80,40 @@ class TestCollectorShapesMatchSchema:
assert result["uncovered_interfaces"] == ["eth1"]
def test_dnsmasq_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.dnsmasq, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = lib.state._collect_dnsmasq()
result = daemon.collectors.dnsmasq._collect_dnsmasq()
assert not _missing(schema.DnsmasqState.__required_keys__, result)
for k in schema.DnsmasqStatus.__required_keys__:
assert k in result["status"], f"DnsmasqStatus missing {k}"
def test_nginx_state(self):
result = lib.state._collect_nginx()
result = daemon.collectors.nginx._collect_nginx()
assert not _missing(schema.NginxState.__required_keys__, result)
assert "pending_changes" in result["status"]
def test_acme_state(self):
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
lib.state,
daemon.collectors.acme,
"_parse_account_conf",
return_value={"registered": False, "email": "", "ca": ""},
),
):
result = lib.state._collect_acme()
result = daemon.collectors.acme._collect_acme()
assert not _missing(schema.AcmeState.__required_keys__, result)
assert result["status"]["error"] is None
def test_wireguard_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.wireguard, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="", returncode=1)
result = lib.state._collect_wireguard()
result = daemon.collectors.wireguard._collect_wireguard()
assert not _missing(schema.WgState.__required_keys__, result)
for k in schema.WgStatus.__required_keys__:
@@ -134,8 +143,10 @@ class TestCollectorShapesMatchSchema:
}
]
}
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
result = lib.state._collect_networkd()
with patch.object(
daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
):
result = daemon.collectors.networkd._collect_networkd()
assert not _missing(schema.NetworkdState.__required_keys__, result)
assert "eth0" in result["interfaces"]
@@ -147,7 +158,7 @@ class TestCollectorShapesMatchSchema:
def test_system_state(self):
"""Reads /proc and /sys directly — no mocking needed on Linux."""
result = lib.state._collect_system()
result = daemon.collectors.system._collect_system()
assert not _missing(schema.SystemState.__required_keys__, result)
for k in schema.CpuLoad.__required_keys__:
assert k in result["load"], f"CpuLoad missing {k}"
+62 -12
View File
@@ -3,6 +3,9 @@
import json
from unittest.mock import patch
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
from lib.state import State, state
@@ -50,9 +53,9 @@ class TestState:
class TestCollectAll:
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_returns_dict(self, mock_run):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -87,10 +90,10 @@ class TestCollectAll:
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -143,10 +146,10 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions")
@patch("lib.state.run")
@patch("daemon.collectors.firewall.get_service_descriptions")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -167,11 +170,11 @@ class TestCollectAll:
mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
from lib.state import _collect_dnsmasq
from daemon.collectors.dnsmasq import _collect_dnsmasq
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
@@ -180,12 +183,12 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
from daemon.collectors.dnsmasq import _collect_dnsmasq
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = {
@@ -225,7 +228,9 @@ class TestCollectAll:
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
monkeypatch.setattr(
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
@@ -249,6 +254,51 @@ class TestCollectFailure:
assert s.is_populated() is False
_ACCOUNT = {"registered": False, "email": "", "ca": ""}
class TestAcmeCollectNonFatal:
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
def test_list_failure_yields_empty_certs_and_error(self):
from daemon.collectors.acme import _collect_acme
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"),
),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
assert result["certs"] == []
assert result["email"] == "a@b.c"
assert result["status"]["error"] is not None
assert "exit code 2" in result["status"]["error"]
def test_success_reports_no_error(self):
from daemon.collectors.acme import _collect_acme
with (
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
assert result["status"] == {"error": None}
class TestStateVersions:
def test_version_starts_at_zero(self):
s = State()
+39
View File
@@ -377,6 +377,45 @@ class TestStatusApplyAll:
assert "Firewall" in result["errors"]
mock_nginx.assert_called_once()
def test_force_body_forwarded_to_firewall_only(self):
mock_fw = MagicMock()
mock_nginx = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["nginx"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"firewall": mock_fw,
"nginx": mock_nginx,
},
),
):
status.status_apply_all(None, {"force": True})
mock_fw.assert_called_once_with(None, {"force": True})
mock_nginx.assert_called_once_with(None, None)
def test_no_body_passed_without_force(self):
mock_fw = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
):
status.status_apply_all(None, None)
mock_fw.assert_called_once_with(None, None)
class TestSysOrder:
"""Verify SYS_ORDER and SYS_LABELS constants."""
+10 -2
View File
@@ -226,10 +226,14 @@ class TestGetAffected:
class TestDnsToFirewallSync:
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
@patch("lib.dnsmasq.save_config")
@patch("lib.firewall.save_config")
@patch("lib.firewall.get_config")
@patch("lib.dnsmasq.get_config")
def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save):
def test_adds_dhcp_dns(
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
):
mock_dm_get.return_value = {
"dhcp": {
"ranges": [
@@ -288,10 +292,14 @@ class TestDnsToFirewallSync:
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
@patch("lib.dnsmasq.save_config")
@patch("lib.firewall.save_config")
@patch("lib.firewall.get_config")
@patch("lib.dnsmasq.get_config")
def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save):
def test_idempotent(
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
):
mock_dm_get.return_value = {
"dhcp": {
"ranges": [
+84 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
from lib import system_import
from lib.common import save_json
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, save_json
@pytest.fixture
@@ -137,6 +137,39 @@ class TestImportDnsmasq:
):
assert not system_import.import_dnsmasq()
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
# Existing config differs from the live conf and carries apply
# bookkeeping — the rewrite must keep the baseline so pending
# detection and cancel-all survive daemon restarts.
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
cfg_path = tmp_path / "config" / "dnsmasq"
cfg_path.mkdir(parents=True, exist_ok=True)
baseline = {"dns": {"upstreams": ["1.1.1.1"]}}
save_json(
cfg_path / "config.json",
{
**baseline,
_APPLY_HASH_KEY: "old-hash",
_LAST_APPLIED_CONFIG_KEY: baseline,
},
)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == "old-hash"
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
assert cfg["dns"]["upstreams"] == ["8.8.8.8"]
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
# No config file yet: the imported content is the running state,
# so it must be stamped as applied (no phantom pending changes).
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["dns"]["upstreams"] == ["8.8.8.8"]
# ──────────────────────────────────────────────────────────────────────
# WireGuard
@@ -232,6 +265,44 @@ class TestImportWireguard:
assert system_import.import_wireguard()
assert not system_import.import_wireguard()
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
cfg_path = tmp_path / "config" / "wireguard"
cfg_path.mkdir(parents=True, exist_ok=True)
baseline = {"interface": {"listen_port": 51821}, "peers": {}}
save_json(
cfg_path / "config.json",
{
**baseline,
_APPLY_HASH_KEY: "old-hash",
_LAST_APPLIED_CONFIG_KEY: baseline,
},
)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == "old-hash"
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
assert cfg["interface"]["listen_port"] == 51820
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["interface"]["private_key"] == "abc123"
# ──────────────────────────────────────────────────────────────────────
# Networkd
@@ -593,6 +664,18 @@ class TestImportFirewall:
cfg = self._read_json(tmp_path)
assert "dmz" not in cfg["zones"]
def test_import_stamps_applied(self, temp_project, tmp_path):
# Fresh import adopts the live firewalld state, which is by
# definition the applied state — the file must carry a baseline.
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY]["zones"]["public"]["interfaces"] == [
"eth0",
"eth1",
]
def test_parse_error_returns_false(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
assert not system_import.import_firewall()
+38 -2
View File
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
" listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show_output(output)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self):
result = wireguard._parse_wg_show_output("")
result = wireguard.parse_wg_show_output("")
assert result["up"] is False
assert result["peers"] == []
def test_parses_fwmark(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" fwmark: 0x0\n"
)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["fwmark"] == "0x0"
def test_peer_transfer_and_keepalive(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" peer: PUBKEY1\n"
" endpoint: 203.0.113.1:51820\n"
" allowed ips: 10.0.0.0/24, 10.0.1.0/24\n"
" latest handshake: 2 minutes ago\n"
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
" persistent-keepalive: 25\n"
)
result = wireguard.parse_wg_show_output(output)
peer = result["peers"][0]
assert peer["allowed_ips"] == ["10.0.0.0/24", "10.0.1.0/24"]
assert peer["latest_handshake"] == "2 minutes ago"
assert peer["transfer_received"] == "1.23 GiB received"
assert peer["transfer_sent"] == "4.56 GiB sent"
assert peer["persistent_keepalive"] == 25
def test_bad_keepalive_value(self):
output = "interface: wg0\n peer: PUBKEY1\n persistent-keepalive: bogus\n"
result = wireguard.parse_wg_show_output(output)
assert result["peers"][0]["persistent_keepalive"] is None
class TestAccessClasses:
def test_default_config_has_access_classes(self):
+75 -239
View File
@@ -3,11 +3,11 @@
Exposed at /api/certs/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
from daemon.client import delete, get, post # noqa: F401 (resolved via module globals)
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE,
@@ -22,269 +22,105 @@ from daemon.iface import (
POST_ACME_RENEW,
POST_ACME_VALIDATE,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("certs", __name__)
@bp.route("/list", methods=["GET"])
def list_certs_bp():
"""GET /api/certs/list — list all managed ACME certificates.
Returns:
Response containing the list of certificates or an error message.
"""
try:
return _ok(get(GET_ACME_LIST))
except RuntimeError as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500)
def _validate_body(request: Any, _va: Any) -> dict[str, Any]:
domain = ((request.get_json(silent=True) or {}).get("domain") or "").strip()
if not domain:
raise ValueError("'domain' is required")
return {"domain": domain}
@bp.route("/<domain>", methods=["GET"])
def cert_details(domain: str):
"""GET /api/certs/<domain> — get details for a specific certificate.
Args:
domain: Domain name to look up.
Returns:
Response containing certificate info or an error message.
"""
try:
return _ok(get(GET_ACME_INFO, {"domain": domain}))
except NotFound as exc:
logger.info("Cert for '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/validate", methods=["POST"])
def validate():
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
Expects JSON body with ``{``domain``}``.
Returns:
Response containing validation results or an error message.
"""
def _issue_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
result = post(POST_ACME_VALIDATE, {"domain": domain})
return _ok(result)
except BadRequest as exc:
logger.info("Validation rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to validate cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/start", methods=["POST"])
def issue_start():
"""POST /api/certs/issue/start — create a new certificate issuance request.
Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``.
Returns:
Response containing an issuance request ID or an error message.
"""
body = request.get_json(silent=True) or {}
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
raise ValueError("'domain' is required")
email = (body.get("email") or "").strip() or None
webroot = body.get("webroot")
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
result = post(
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
)
logger.info(
"Certificate issuance started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except Conflict as exc:
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
return {"domain": domain, "webroot": body.get("webroot"), "email": email}
@bp.route("/issue/<request_id>", methods=["GET"])
def issue_status(request_id: str):
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request.
Args:
request_id: Issuance request identifier returned by issue_start.
Returns:
Response containing issuance status or an error message.
"""
try:
result = get(GET_ACME_ISSUE_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Issuance request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
def _email_body(request: Any, _va: Any) -> dict[str, Any]:
email = ((request.get_json(silent=True) or {}).get("email") or "").strip()
if not email:
raise ValueError("'email' is required")
return {"email": email}
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
Returns:
Response containing a renewal request ID (poll it at
``/api/certs/renew/<request_id>``) or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
result = post(POST_ACME_RENEW, {"domain": domain})
logger.info(
"Certificate renewal started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to renew cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
def _register_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
raise ValueError("'email' is required")
return {"email": email, "server": (body.get("server") or "").strip()}
@bp.route("/renew/<request_id>", methods=["GET"])
def renew_status(request_id: str):
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
Args:
request_id: Renewal request identifier returned by renew_bp.
Returns:
Response containing renewal status or an error message.
"""
try:
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Renewal request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
def _email_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"email": sent["email"]}
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
Args:
domain: Domain name whose certificate should be removed.
Returns:
Response confirming removal or an error message.
"""
try:
delete(DELETE_ACME_REMOVE, {"domain": domain})
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None)
except NotFound as exc:
logger.info("Cert '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500)
@daemon_route(GET_ACME_LIST, bp)
def list_certs_bp():
"""GET /api/certs/list — List all managed ACME certificates."""
@bp.route("/email", methods=["POST"])
@daemon_route(GET_ACME_INFO, bp, rule="/<domain>")
def cert_details():
"""GET /api/certs/<domain> — Get details for a specific certificate."""
@daemon_route(POST_ACME_VALIDATE, bp, body=_validate_body)
def validate():
"""POST /api/certs/validate — Run pre-flight checks for issuance."""
@daemon_route(POST_ACME_ISSUE, bp, rule="/issue/start", body=_issue_body)
def issue_start():
"""POST /api/certs/issue/start — Create a new certificate issuance request."""
@daemon_route(
GET_ACME_ISSUE_STATUS, bp, rule="/issue/<request_id>", params={"id": "request_id"}
)
def issue_status():
"""GET /api/certs/issue/<request_id> — Poll status of an issuance request."""
@daemon_route(POST_ACME_RENEW, bp, rule="/<domain>/renew")
def renew_bp():
"""POST /api/certs/<domain>/renew — Start an (async) certificate renewal."""
@daemon_route(
GET_ACME_RENEW_STATUS, bp, rule="/renew/<request_id>", params={"id": "request_id"}
)
def renew_status():
"""GET /api/certs/renew/<request_id> — Poll status of a certificate renewal."""
@daemon_route(DELETE_ACME_REMOVE, bp, rule="/<domain>", transform=void_transform)
def remove_bp():
"""DELETE /api/certs/<domain> — Remove a certificate from ACME management."""
@daemon_route(POST_ACME_EMAIL, bp, body=_email_body, transform=_email_echo)
def set_email_bp():
"""POST /api/certs/email — set the ACME account email address.
Expects JSON body with ``{``email``}``.
Returns:
Response confirming the email was set or an error message.
"""
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
try:
post(POST_ACME_EMAIL, {"email": email})
logger.info("ACME email set via API: %s", email)
return _ok({"email": email})
except BadRequest as exc:
logger.info("ACME email set rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500)
"""POST /api/certs/email — Set the ACME account email address."""
@bp.route("/account", methods=["GET"])
@daemon_route(GET_ACME_ACCOUNT, bp)
def account():
"""GET /api/certs/account — return ACME account information.
Returns:
Response containing account status or an error message.
"""
try:
result = get(GET_ACME_ACCOUNT)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to get ACME account: %s", exc)
return _error(str(exc), 500)
"""GET /api/certs/account — Return ACME account information."""
@bp.route("/account/register", methods=["POST"])
@daemon_route(POST_ACME_ACCOUNT_REGISTER, bp, body=_register_body)
def register_account():
"""POST /api/certs/account/register — register a new ACME account.
Expects JSON body with ``{``email``, ``server``?}``.
Returns:
Response confirming registration or an error message.
"""
body = request.get_json(silent=True) or {}
email = (body.get("email") or "").strip()
if not email:
return _error("'email' is required", 400)
server = (body.get("server") or "").strip()
try:
result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server})
return _ok(result)
except BadRequest as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to register ACME account: %s", exc)
return _error(str(exc), 500)
"""POST /api/certs/account/register — Register a new ACME account."""
@bp.route("/account", methods=["DELETE"])
@daemon_route(DELETE_ACME_ACCOUNT_DEACTIVATE, bp, rule="/account", body=NO_BODY)
def deactivate_account():
"""DELETE /api/certs/account — deactivate the ACME account.
Returns:
Response confirming deactivation or an error message.
"""
try:
result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE)
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to deactivate ACME account: %s", exc)
return _error(str(exc), 500)
"""DELETE /api/certs/account — Deactivate the ACME account."""
+185 -6
View File
@@ -1,14 +1,36 @@
"""Shared API response helpers.
"""Shared API response helpers + daemon-proxy route factory.
Used by all API blueprints to produce consistent JSON responses
per the API response contract: ``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``.
Used by all API blueprints to produce consistent JSON responses per the
API response contract (``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``) and to collapse the repetitive
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
single declarative ``daemon_route`` decorator.
The factory dispatches to the ``daemon.client`` verb imported into the
blueprint's own module namespace (resolved via ``sys.modules`` at request
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
"""
from flask import jsonify
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from flask import Blueprint, jsonify, request
from daemon.client import BadRequest, Conflict, NotFound
from daemon.iface import Endpoint
logger = logging.getLogger(__name__)
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
NO_BODY = object()
Verb = Callable[..., Any]
def _ok(data=None):
def _ok(data: Any = None):
"""Return a success JSON response."""
return jsonify({"ok": True, "data": data})
@@ -16,3 +38,160 @@ def _ok(data=None):
def _error(msg: str, code: int = 400):
"""Return an error JSON response with the given HTTP status code."""
return jsonify({"ok": False, "error": msg}), code
def _derive_rule(path: str) -> str:
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
daemon endpoint path by dropping the leading subsystem segment.
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
``/issue/status``.
"""
parts = path.lstrip("/").split("/")
if len(parts) <= 1:
return "/"
return "/" + "/".join(parts[1:])
def _map_view_args(
view_args: dict[str, Any], params: dict[str, str] | None
) -> dict[str, Any]:
"""Map Flask view args onto daemon body keys.
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
not listed maps to itself (identity), so path params are always
forwarded and only renamed where the daemon expects a different key.
"""
result = dict(view_args)
for body_key, view_arg_name in (params or {}).items():
result.pop(view_arg_name, None)
result[body_key] = view_args[view_arg_name]
return result
def daemon_route(
endpoint: Endpoint,
bp: Blueprint,
rule: str | None = None,
methods: tuple[str, ...] | list[str] | None = None,
*,
params: dict[str, str] | None = None,
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
body: Any | None = None,
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
) -> Callable[..., Any]:
"""Decorator factory for thin daemon-proxy routes.
Args:
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
is the daemon HTTP verb.
bp: The target blueprint.
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
to the endpoint path minus its leading subsystem segment.
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
override where the UI verb differs from the daemon verb
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
params: ``{body_key: view_arg_name}`` renames for path params.
precheck: ``(json, view_args) -> None`` run before dispatch; raise
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
validation the daemon does not perform). ``json`` is the raw
``request.get_json(silent=True)`` result.
body: How to build the daemon request body for non-GET verbs.
``None`` (default): ``{**json, **mapped_view_args}``;
``NO_BODY``: send no body argument;
a callable ``(request, view_args) -> dict``: custom body (raise
``ValueError`` for 400);
a ``dict``: fixed body (merged with mapped view args).
transform: ``(data, view_args, sent_body) -> data`` applied to the
daemon result before wrapping in ``_ok()``; may raise a typed
exception to emit an error (e.g. 400 when a result is invalid).
Returns:
A decorator that registers the route and returns a view function
whose ``__name__``/``__doc__`` are inherited from the decorated
function.
"""
if rule is None:
rule = _derive_rule(endpoint[1])
if methods is None:
methods = [endpoint[0]]
daemon_method = endpoint[0]
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
# The decorated view lives in the blueprint's module; its ``__globals__``
# is that module's namespace. Resolving the daemon verb here (instead of
# ``daemon.client`` directly) means tests patching
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
module_globals = fn.__globals__
def view(**view_args: Any) -> Any:
try:
json: Any = request.get_json(silent=True)
if precheck is not None:
precheck(json, view_args)
mapped = _map_view_args(view_args, params)
verb_fn: Verb = module_globals[daemon_method.lower()]
if daemon_method == "GET":
sent_body = mapped
data = (
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
)
elif body is NO_BODY:
sent_body = None
data = verb_fn(endpoint)
elif callable(body):
built = body(request, view_args)
sent_body = built
data = (
verb_fn(endpoint, built)
if built is not None
else verb_fn(endpoint)
)
elif isinstance(body, dict):
sent_body = {**body, **mapped}
data = verb_fn(endpoint, sent_body)
else:
base = json if isinstance(json, dict) else {}
sent_body = {**base, **mapped}
data = verb_fn(endpoint, sent_body)
if transform is not None:
data = transform(data, view_args, sent_body)
return _ok(data)
except (BadRequest, ValueError) as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error(
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
)
return _error(str(exc), 500)
view.__name__ = fn.__name__
view.__doc__ = fn.__doc__
bp.add_url_rule(rule, view_func=view, methods=list(methods))
return view
return decorator
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
"""Precheck: reject a non-dict JSON body (400).
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
"""
if json is not None and not isinstance(json, dict):
raise ValueError("Request body must be a JSON object")
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
"""Transform: discard the daemon result and return ``data: null``.
Matches routes that historically responded ``_ok(None)`` (the daemon
result was intentionally ignored by the caller).
"""
return None
+130 -263
View File
@@ -3,11 +3,16 @@
Exposed at /api/dhcp/* and delegates all operations to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
@@ -23,252 +28,139 @@ from daemon.iface import (
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("dhcp", __name__)
# ---------------------------------------------------------------------------
# Config
# Config / status
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
@daemon_route(GET_DNSMASQ_CONFIG, bp)
def get_config_bp():
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration.
Returns:
JSON response with the config or an error.
"""
try:
return _ok(get(GET_DNSMASQ_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read DHCP config: %s", exc)
return _error(str(exc), 500)
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config():
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration.
Args:
request: JSON body containing the complete config object.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save DHCP config: %s", exc)
return _error(str(exc), 500)
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_DNSMASQ_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config():
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration.
Args:
request: JSON body containing the fields to update.
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_DNSMASQ_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("DHCP config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch DHCP config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration."""
@bp.route("/apply", methods=["POST"])
@daemon_route(POST_DNSMASQ_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
try:
post(POST_DNSMASQ_APPLY)
logger.info("dnsmasq config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply dnsmasq config: %s", exc)
return _error(str(exc), 500)
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration."""
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
@daemon_route(GET_DNSMASQ_STATUS, bp)
def status_bp():
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
try:
return _ok(get(GET_DNSMASQ_STATUS))
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DHCP ranges
# ---------------------------------------------------------------------------
@bp.route("/ranges", methods=["POST"])
def add_range_bp():
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
Args:
request: JSON body with `interface`, `start`, `end`, and optional `lease_time`.
Returns:
JSON response with success status or an error.
"""
def _add_range_body(request: Any, _va: Any) -> dict[str, Any]:
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")
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:
return _error("'start' and 'end' are required", 400)
try:
post(
POST_DNSMASQ_RANGES_ADD,
{
raise ValueError("'start' and 'end' are required")
return {
"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)
"lease_time": body.get("lease_time", "12h"),
}
@bp.route("/ranges", methods=["DELETE"])
def remove_range_bp():
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
Args:
request: JSON body with `interface`, `start`, and `end`.
Returns:
JSON response with success status or an error.
"""
def _remove_range_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
end = body.get("end", "").strip()
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:
return _error("'start' and 'end' are required", 400)
try:
delete(
raise ValueError("'start' and 'end' are required")
return {"interface": iface, "start": start, "end": end}
@daemon_route(
POST_DNSMASQ_RANGES_ADD,
bp,
rule="/ranges",
body=_add_range_body,
transform=void_transform,
)
def add_range_bp():
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface."""
@daemon_route(
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)
bp,
rule="/ranges",
body=_remove_range_body,
transform=void_transform,
)
def remove_range_bp():
"""DELETE /api/dhcp/ranges — Remove a DHCP address range."""
# ---------------------------------------------------------------------------
# Leases
# ---------------------------------------------------------------------------
@bp.route("/leases", methods=["GET"])
@daemon_route(GET_DNSMASQ_LEASES, bp)
def leases_bp():
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
try:
return _ok(get(GET_DNSMASQ_LEASES))
except RuntimeError as exc:
logger.error("Failed to read lease table: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Static leases
# ---------------------------------------------------------------------------
@bp.route("/static-lease", methods=["POST"])
def add_static_lease_bp():
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address.
Args:
request: JSON body with `mac`, `ip`, and optional `hostname`.
Returns:
JSON response with lease details or an error.
"""
def _add_static_lease_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
mac = body.get("mac", "").strip()
ip = body.get("ip", "").strip()
hostname = body.get("hostname")
mac = (body.get("mac") or "").strip()
ip = (body.get("ip") or "").strip()
if not mac or not ip:
return _error("'mac' and 'ip' are required", 400)
try:
post(
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
)
logger.info("Static lease added via API: %s -> %s", mac, ip)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except BadRequest as exc:
logger.info("Add static lease rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add static lease: %s", exc)
return _error(str(exc), 500)
raise ValueError("'mac' and 'ip' are required")
return {"mac": mac, "ip": ip, "hostname": body.get("hostname")}
@bp.route("/static-lease/<mac>", methods=["DELETE"])
def remove_static_lease_bp(mac):
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
def _static_lease_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"mac": sent["mac"], "ip": sent["ip"], "hostname": sent["hostname"]}
Args:
mac: MAC address of the static lease to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
logger.info("Static lease removed via API: %s", mac)
return _ok(None)
except NotFound as exc:
logger.info("Static lease '%s' not found: %s", mac, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove static lease '%s': %s", mac, exc)
return _error(str(exc), 500)
@daemon_route(
POST_DNSMASQ_STATIC_LEASE_ADD,
bp,
rule="/static-lease",
body=_add_static_lease_body,
transform=_static_lease_echo,
)
def add_static_lease_bp():
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address."""
@daemon_route(
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
bp,
rule="/static-lease/<mac>",
transform=void_transform,
)
def remove_static_lease_bp():
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC."""
# ---------------------------------------------------------------------------
@@ -276,35 +168,42 @@ def remove_static_lease_bp(mac):
# ---------------------------------------------------------------------------
@bp.route("/dns-record", methods=["POST"])
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record.
Args:
request: JSON body with `name`, `address`, and optional `hostname`.
Returns:
JSON response with record details or an error.
"""
def _add_dns_record_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
address = body.get("address", "").strip()
hostname = body.get("hostname")
name = (body.get("name") or "").strip()
address = (body.get("address") or "").strip()
if not name or not address:
return _error("'name' and 'address' are required", 400)
try:
post(
raise ValueError("'name' and 'address' are required")
return {"name": name, "address": address, "hostname": body.get("hostname")}
def _dns_record_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {
"name": sent["name"],
"address": sent["address"],
"hostname": sent["hostname"],
}
@daemon_route(
POST_DNSMASQ_DNS_RECORD_ADD,
{"name": name, "address": address, "hostname": hostname},
)
logger.info("DNS record added via API: %s -> %s", name, address)
return _ok({"name": name, "address": address, "hostname": hostname})
except BadRequest as exc:
logger.info("Add DNS record rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add DNS record: %s", exc)
return _error(str(exc), 500)
bp,
rule="/dns-record",
body=_add_dns_record_body,
transform=_dns_record_echo,
)
def add_dns_record_bp():
"""POST /api/dhcp/dns-record — Add a DNS record."""
@daemon_route(
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
bp,
rule="/dns-record/<name>",
transform=void_transform,
)
def remove_dns_record_bp():
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name."""
# ---------------------------------------------------------------------------
@@ -312,48 +211,16 @@ def add_dns_record_bp():
# ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"])
def _set_domain_body(request: Any, _va: Any) -> dict[str, Any]:
return {"domain": (request.get_json(silent=True) or {}).get("domain")}
@daemon_route(
POST_DNSMASQ_DOMAIN,
bp,
precheck=require_dict_body,
body=_set_domain_body,
transform=void_transform,
)
def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
Args:
name: Name of the DNS record to remove.
Returns:
JSON response with success status or an error.
"""
try:
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
logger.info("DNS record removed via API: %s", name)
return _ok(None)
except NotFound as exc:
logger.info("DNS record '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove DNS record '%s': %s", name, exc)
return _error(str(exc), 500)
"""POST /api/dhcp/domain — Set or clear the DNS search domain."""
+248 -523
View File
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -30,169 +36,179 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__)
# ---------------------------------------------------------------------------
# Body builders / prechecks / transforms
# ---------------------------------------------------------------------------
def _config_save_precheck(json: Any, _va: Any) -> None:
body = json or {}
if "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
def _interfaces_precheck(json: Any, _va: Any) -> None:
if not isinstance((json or {}).get("interfaces", []), list):
raise ValueError("'interfaces' must be a list")
def _services_precheck(json: Any, _va: Any) -> None:
if not isinstance((json or {}).get("services", []), list):
raise ValueError("'services' must be a list")
def _pending_data() -> dict[str, Any] | None:
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
return {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
# The save already succeeded; the follow-up read is best-effort so a
# failure degrades to a bare ``config_saved`` rather than a 500.
logger.warning("Failed to read pending state after config save: %s", exc)
return None
def _config_saved(_data: Any, _va: Any, _sent: Any) -> Any:
return {"config_saved": True, **(_pending_data() or {})}
def _create_zone_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone_name = (body.get("name") or "").strip()
if not zone_name:
raise ValueError("Zone name is required")
target = (body.get("target") or "").strip() or "default"
return {"name": zone_name, "target": target}
def _zones_list(data: Any, _va: Any, _sent: Any) -> Any:
return {"active": data.get("active", {}), "available": data.get("available", [])}
def _zone_interfaces_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "interfaces": sent.get("interfaces", [])}
def _zone_services_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "services": sent.get("services", [])}
def _add_rich_rule_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
rule = (body.get("rule") or "").strip()
if not zone or not rule:
raise ValueError("Both 'zone' and 'rule' are required")
return {"zone": zone, "rule": rule}
def _rich_rule_add_echo(data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "id": data["id"], "rule": sent["rule"]}
def _rich_rule_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "id": sent["id"]}
def _masquerade_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
enable = body.get("enable")
if not zone or enable is None:
raise ValueError("'zone' and 'enable' (bool) are required")
return {"zone": zone, "enable": bool(enable)}
def _masquerade_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "masquerade": sent["enable"]}
def _add_forward_port_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
zone = (body.get("zone") or "").strip()
port = body.get("port")
proto = (body.get("proto") or "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required")
try:
port_int = int(port)
except ValueError:
raise ValueError("'port' must be an integer") from None
toport_int = None
if toport is not None:
try:
toport_int = int(toport)
except ValueError:
raise ValueError("'toport' must be an integer") from None
return {
"zone": zone,
"port": port_int,
"proto": proto,
"toaddr": str(toaddr) if toaddr else None,
"toport": toport_int,
}
def _forward_port_add_echo(data: Any, _va: Any, sent: Any) -> Any:
return {
"zone": sent["zone"],
"id": data["id"],
"port": sent["port"],
"proto": sent["proto"],
}
def _forward_port_remove_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"zone": sent["zone"], "port": sent["port"], "proto": sent["proto"]}
# ---------------------------------------------------------------------------
# Declarative config (two-step: save -> apply)
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG, bp)
def config_list():
"""Retrieve the current firewall declarative configuration.
Returns JSON containing the full firewall config from the daemon.
Endpoint:
GET /api/firewall/config
Returns:
JSON response with the config data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config — Retrieve the current firewall config."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
)
def config_save():
"""Save a new firewall declarative configuration.
Validates that the request body contains a ``zones`` dict, forwards
to the daemon, and returns the pending state including unmanaged zones.
Endpoint:
POST /api/firewall/config
Args:
body: JSON with ``zones`` dict mapping zone names to zone configs.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if "zones" not in body:
return _error("'zones' key is required", 400)
if not isinstance(body["zones"], dict):
return _error("'zones' must be a dict", 400)
try:
post(POST_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config save: %s", exc)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config — Save a new firewall declarative configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
)
def patch_config():
"""Partially update the firewall declarative configuration.
Accepts a JSON body and forwards it as a patch to the daemon config
endpoint, returning the updated pending state.
Endpoint:
PATCH /api/firewall/config
Args:
body: JSON object with configuration fields to patch.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config patch: %s", exc)
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch firewall config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/firewall/config — Partially update the firewall configuration."""
@bp.route("/config/apply", methods=["POST"])
@daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
def config_apply_bp():
"""Apply any pending firewall configuration changes.
Triggers the daemon to apply saved declarative config to the live
firewalld instance.
Endpoint:
POST /api/firewall/config/apply
Returns:
JSON with ``applied_zones`` list or an error message.
"""
try:
result = post(POST_FIREWALL_CONFIG_APPLY)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config/apply — Apply pending firewall config changes."""
@bp.route("/config/pending", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
def config_pending_bp():
"""Check the pending firewall configuration state.
Returns information about unsaved changes, whether an apply is
needed, and any unmanaged zones detected on the system.
Endpoint:
GET /api/firewall/config/pending
Returns:
JSON with pending changes and apply status.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config/pending — Check the pending firewall config state."""
# ---------------------------------------------------------------------------
@@ -200,21 +216,9 @@ def config_pending_bp():
# ---------------------------------------------------------------------------
@bp.route("/state", methods=["GET"])
@daemon_route(GET_FIREWALL_STATE, bp)
def get_state():
"""Retrieve current firewall state from the state store.
Endpoint:
GET /api/firewall/state
Returns:
JSON with firewall state data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_STATE))
except RuntimeError as exc:
logger.error("Failed to get firewall state: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/state — Retrieve current firewall state."""
# ---------------------------------------------------------------------------
@@ -222,182 +226,67 @@ def get_state():
# ---------------------------------------------------------------------------
@bp.route("/zones", methods=["GET"])
@daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
def list_zones():
"""List all active and available firewall zones.
Endpoint:
GET /api/firewall/zones
Returns:
JSON with ``active`` zones dict and ``available`` zones list.
"""
try:
data = get(GET_FIREWALL_ZONES)
return _ok(
{"active": data.get("active", {}), "available": data.get("available", [])}
)
except RuntimeError as exc:
logger.error("Failed to list zones: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/zones — List active and available firewall zones."""
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name: str):
"""Retrieve details for a specific firewall zone.
Endpoint:
GET /api/firewall/zones/<name>
Args:
name: Name of the zone to look up.
Returns:
JSON with zone configuration details or 404 error.
"""
try:
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
return _ok(info)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
)
def zone_details():
"""GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
@bp.route("/zones", methods=["POST"])
@daemon_route(
POST_FIREWALL_ZONES_CREATE,
bp,
rule="/zones",
body=_create_zone_body,
transform=void_transform,
)
def create_zone_bp():
"""Create a new firewall zone.
Endpoint:
POST /api/firewall/zones
Args:
body: JSON with ``name`` (required) and optional ``target`` string.
Returns:
JSON confirmation or error if the zone already exists.
"""
body = request.get_json(silent=True) or {}
zone_name = body.get("name", "").strip()
target = body.get("target", "default").strip() or "default"
if not zone_name:
return _error("Zone name is required", 400)
try:
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
logger.info("Zone '%s' created via API", zone_name)
return _ok(None)
except BadRequest as exc:
logger.info("Zone '%s' creation rejected: %s", zone_name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to create zone '%s': %s", zone_name, exc)
return _error(str(exc), 500)
"""POST /api/firewall/zones — Create a new firewall zone."""
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name: str):
"""Delete a firewall zone by name.
Endpoint:
DELETE /api/firewall/zones/<name>
Args:
name: Name of the zone to delete.
Returns:
JSON confirmation or 404 if the zone does not exist.
"""
try:
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
logger.info("Zone '%s' deleted via API", name)
return _ok(None)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to delete zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_ZONES_DELETE,
bp,
rule="/zones/<name>",
params={"zone": "name"},
transform=void_transform,
)
def delete_zone_bp():
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
# ---------------------------------------------------------------------------
# Zone interfaces
# Zone interfaces / services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name: str):
"""Set the network interfaces assigned to a firewall zone.
Replaces all existing interfaces for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/interfaces
Args:
name: Zone name.
body: JSON with ``interfaces`` list of interface names.
Returns:
JSON confirmation with zone and updated interfaces list.
"""
body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list):
return _error("'interfaces' must be a list", 400)
try:
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except BadRequest as exc:
logger.info("Set interfaces for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_INTERFACES,
bp,
rule="/zones/<name>/interfaces",
params={"zone": "name"},
precheck=_interfaces_precheck,
transform=_zone_interfaces_echo,
)
def set_zone_interfaces_bp():
"""POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
# ---------------------------------------------------------------------------
# Zone services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name: str):
"""Set the allowed services for a firewall zone.
Replaces all existing services for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/services
Args:
name: Zone name.
body: JSON with ``services`` list of service names.
Returns:
JSON confirmation with zone and updated services list.
"""
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
return _error("'services' must be a list", 400)
try:
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
return _ok({"zone": name, "services": services})
except BadRequest as exc:
logger.info("Set services for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_SERVICES,
bp,
rule="/zones/<name>/services",
params={"zone": "name"},
precheck=_services_precheck,
transform=_zone_services_echo,
)
def set_zone_services_bp():
"""POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
# ---------------------------------------------------------------------------
@@ -405,38 +294,14 @@ def set_zone_services_bp(name: str):
# ---------------------------------------------------------------------------
@bp.route("/services", methods=["GET"])
@daemon_route(GET_FIREWALL_SERVICES, bp)
def list_services():
"""List all available firewall services.
Endpoint:
GET /api/firewall/services
Returns:
JSON with the list of available service names.
"""
try:
return _ok(get(GET_FIREWALL_SERVICES))
except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/services — List all available firewall services."""
@bp.route("/interfaces", methods=["GET"])
@daemon_route(GET_FIREWALL_INTERFACES, bp)
def list_interfaces():
"""List all available network interfaces.
Endpoint:
GET /api/firewall/interfaces
Returns:
JSON with the list of available interface names.
"""
try:
return _ok(get(GET_FIREWALL_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/interfaces — List all available network interfaces."""
# ---------------------------------------------------------------------------
@@ -444,80 +309,31 @@ def list_interfaces():
# ---------------------------------------------------------------------------
@bp.route("/rich-rules", methods=["POST"])
@daemon_route(
POST_FIREWALL_RICH_RULES_ADD,
bp,
rule="/rich-rules",
body=_add_rich_rule_body,
transform=_rich_rule_add_echo,
)
def add_rich_rule_bp():
"""Add a rich rule to a firewall zone.
Endpoint:
POST /api/firewall/rich-rules
Args:
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
Returns:
JSON with zone, generated rule ID, and rule string.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
except BadRequest as exc:
logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/rich-rules — Add a rich rule to a firewall zone."""
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone: str):
"""List rich rules for a specific firewall zone.
Endpoint:
GET /api/firewall/rich-rules/<zone>
Args:
zone: Zone name to list rules for.
Returns:
JSON with list of rich rule entries for the zone.
"""
try:
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
def list_rich_rules():
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone: str, rule_id: str):
"""Remove a rich rule from a firewall zone by ID.
Endpoint:
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
Args:
zone: Zone name.
rule_id: Rule identifier.
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except NotFound as exc:
logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_RICH_RULES_REMOVE,
bp,
rule="/rich-rules/<zone>/<rule_id>",
params={"id": "rule_id"},
transform=_rich_rule_remove_echo,
)
def remove_rich_rule_bp():
"""DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
# ---------------------------------------------------------------------------
@@ -525,38 +341,11 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
# ---------------------------------------------------------------------------
@bp.route("/masquerade", methods=["POST"])
@daemon_route(
POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo
)
def set_masquerade_bp():
"""Enable or disable masquerade (NAT) on a firewall zone.
Endpoint:
POST /api/firewall/masquerade
Args:
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
Returns:
JSON confirmation with zone and masquerade status.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
enable = body.get("enable")
if not zone or enable is None:
return _error("'zone' and 'enable' (bool) are required", 400)
try:
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
logger.info(
"Masquerade %s on zone '%s' via API",
"enabled" if enable else "disabled",
zone,
)
return _ok({"zone": zone, "masquerade": bool(enable)})
except BadRequest as exc:
logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
# ---------------------------------------------------------------------------
@@ -564,86 +353,22 @@ def set_masquerade_bp():
# ---------------------------------------------------------------------------
@bp.route("/forward-port", methods=["POST"])
def add_forward_port_bp():
"""Add a port forwarding rule to a firewall zone.
Endpoint:
POST /api/firewall/forward-port
Args:
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
(tcp/udp), optional ``toaddr`` and ``toport``.
Returns:
JSON confirmation with zone, generated ID, port, and protocol.
"""
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try:
port_int = int(port)
except ValueError:
return _error("'port' must be an integer", 400)
toport_int = None
if toport is not None:
try:
toport_int = int(toport)
except ValueError:
return _error("'toport' must be an integer", 400)
toaddr_str = str(toaddr) if toaddr else None
try:
entry = post(
@daemon_route(
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,
rule="/forward-port",
body=_add_forward_port_body,
transform=_forward_port_add_echo,
)
def add_forward_port_bp():
"""POST /api/firewall/forward-port — Add a port forwarding rule to a zone."""
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone: str, port: int, proto: str):
"""Remove a port forwarding rule from a firewall zone.
Endpoint:
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
Args:
zone: Zone name.
port: Port number.
proto: Protocol (tcp/udp).
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(
@daemon_route(
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)
bp,
rule="/forward-port/<zone>/<int:port>/<proto>",
transform=_forward_port_remove_echo,
)
def remove_forward_port_bp():
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""
+7 -36
View File
@@ -3,11 +3,9 @@
Wraps raw log text in the standard JSON response contract.
"""
import logging
from flask import Blueprint
from daemon.client import NotFound, get
from daemon.client import get # noqa: F401 (resolved via module globals at dispatch)
from daemon.iface import (
GET_LOGS_APP,
GET_LOGS_DNSMASQ,
@@ -15,58 +13,31 @@ from daemon.iface import (
GET_LOGS_NGINX_ACCESS,
GET_LOGS_NGINX_ERROR,
)
from webui.api.common import _error, _ok
from webui.api.common import daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("logs", __name__)
@bp.route("/journal")
@daemon_route(GET_LOGS_JOURNAL, bp)
def journal():
"""GET /api/logs/journal — Return systemd journal log lines."""
try:
return _ok(get(GET_LOGS_JOURNAL))
except RuntimeError:
return _error("error reading journal", 500)
@bp.route("/nginx/access")
@daemon_route(GET_LOGS_NGINX_ACCESS, bp)
def nginx_access():
"""GET /api/logs/nginx/access — Return nginx access log lines."""
try:
return _ok(get(GET_LOGS_NGINX_ACCESS))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
@bp.route("/nginx/error")
@daemon_route(GET_LOGS_NGINX_ERROR, bp)
def nginx_error():
"""GET /api/logs/nginx/error — Return nginx error log lines."""
try:
return _ok(get(GET_LOGS_NGINX_ERROR))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
@bp.route("/dnsmasq")
@daemon_route(GET_LOGS_DNSMASQ, bp)
def dnsmasq():
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
try:
return _ok(get(GET_LOGS_DNSMASQ))
except RuntimeError:
return _error("error reading journal", 500)
@bp.route("/app")
@daemon_route(GET_LOGS_APP, bp)
def app_log():
"""GET /api/logs/app — Return application log lines."""
try:
return _ok(get(GET_LOGS_APP))
except NotFound:
return _error("log file not found", 404)
except RuntimeError:
return _error("error reading log", 500)
+45 -130
View File
@@ -4,11 +4,14 @@ Exposes /api/network/* and delegates to vacuum-walld for interface
IP configuration via systemd-networkd.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import NotFound, get, post
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
get,
post,
)
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
@@ -19,150 +22,62 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
)
from lib.common import validate_interface_name
from webui.api.common import _error, _ok
from webui.api.common import daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("network", __name__)
@bp.route("/interfaces", methods=["GET"])
def _check_iface(_json: Any, view_args: dict[str, Any]) -> None:
"""Validate the interface name path param (400 on a bad name)."""
validate_interface_name(view_args["name"])
def _applied(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
return {"name": view_args["name"], "applied": True}
def _reloaded(_data: Any, view_args: dict[str, Any], _sent: Any) -> Any:
return {"name": view_args["name"], "reloaded": True}
@daemon_route(GET_NETWORK_INTERFACES, bp)
def list_interfaces():
"""List all interfaces with their network config and runtime state.
Endpoint:
GET /api/network/interfaces
Returns:
JSON with interface config + runtime state.
"""
try:
return _ok(get(GET_NETWORK_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list network interfaces: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/interfaces — List interfaces with config + runtime state."""
@bp.route("/interfaces/<name>", methods=["GET"])
def get_interface(name: str):
"""Get config + runtime state for a specific interface.
Endpoint:
GET /api/network/interfaces/<name>
Returns:
JSON with interface config and runtime state.
"""
try:
validate_interface_name(name)
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Interface '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(GET_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface)
def get_interface():
"""GET /api/network/interfaces/<name> — Config + runtime state for one interface."""
@bp.route("/interfaces/<name>", methods=["POST"])
def save_interface(name: str):
"""Save and apply network config for an interface.
Endpoint:
POST /api/network/interfaces/<name>
Args:
body: JSON with addresses, gateway, dns, routes.
Returns:
JSON confirmation.
"""
body = {**(request.get_json(silent=True) or {}), "name": name}
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_NAME, body)
logger.info("Interface '%s' config saved", name)
return _ok({"name": name, "applied": True})
except ValueError as exc:
return _error(str(exc), 400)
except NotFound as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to save interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NETWORK_INTERFACE_NAME, bp, precheck=_check_iface, transform=_applied
)
def save_interface():
"""POST /api/network/interfaces/<name> — Save and apply an interface's config."""
@bp.route("/interfaces/<name>/reload", methods=["POST"])
def reload_interface(name: str):
"""Reload networkd for a single interface.
Endpoint:
POST /api/network/interfaces/<name>/reload
Returns:
JSON confirmation.
"""
try:
validate_interface_name(name)
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
logger.info("Interface '%s' reloaded", name)
return _ok({"name": name, "reloaded": True})
except ValueError as exc:
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to reload interface '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NETWORK_INTERFACE_RELOAD,
bp,
precheck=_check_iface,
body={},
transform=_reloaded,
)
def reload_interface():
"""POST /api/network/interfaces/<name>/reload — Reload networkd for one interface."""
@bp.route("/apply", methods=["POST"])
@daemon_route(POST_NETWORK_APPLY, bp)
def apply_all():
"""Apply network config for ALL interfaces (full sync).
Endpoint:
POST /api/network/apply
Returns:
JSON with number of interfaces applied.
"""
try:
result = post(POST_NETWORK_APPLY, {})
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply network config: %s", exc)
return _error(str(exc), 500)
"""POST /api/network/apply — Apply network config for ALL interfaces."""
@bp.route("/infer-dhcp-ranges", methods=["GET"])
@daemon_route(GET_NETWORK_INFER_DHCP_RANGES, bp)
def infer_dhcp_ranges():
"""Suggest candidate DHCP ranges based on static interface IPs.
Endpoint:
GET /api/network/infer-dhcp-ranges
Returns:
JSON with per-interface suggested DHCP ranges.
"""
try:
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
except RuntimeError as exc:
logger.error("Failed to infer DHCP ranges: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/infer-dhcp-ranges — Suggest candidate DHCP ranges."""
@bp.route("/infer-zones", methods=["GET"])
@daemon_route(GET_NETWORK_INFER_ZONES, bp)
def infer_zones():
"""Suggest firewalld zone assignments for configured interfaces.
Endpoint:
GET /api/network/infer-zones
Returns:
JSON with per-interface suggested zone names.
"""
try:
return _ok(get(GET_NETWORK_INFER_ZONES))
except RuntimeError as exc:
logger.error("Failed to infer zones: %s", exc)
return _error(str(exc), 500)
"""GET /api/network/infer-zones — Suggest firewalld zone assignments."""
+112 -296
View File
@@ -3,11 +3,17 @@
Exposed at /api/proxy/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
BadRequest,
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_NGINX_BACKENDS_REMOVE,
DELETE_NGINX_DOMAINS_REMOVE,
@@ -24,144 +30,59 @@ from daemon.iface import (
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("proxy", __name__)
@bp.route("/ssl-apply", methods=["POST"])
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@daemon_route(POST_NGINX_SSL_APPLY, bp, body=NO_BODY, transform=void_transform)
def ssl_apply_bp():
"""Apply SSL snippet config.
POST /api/proxy/ssl-apply
Returns:
``{"ok": true}`` on success.
Raises:
RuntimeError: If nginx SSL snippet write fails.
"""
try:
post(POST_NGINX_SSL_APPLY)
logger.info("SSL snippet written via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to write SSL snippet: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/ssl-apply — Apply the SSL snippet config."""
@bp.route("/config", methods=["GET"])
@daemon_route(GET_NGINX_CONFIG, bp)
def get_config_bp():
"""Get the current nginx proxy configuration.
GET /api/proxy/config
Returns:
Current config dict from the daemon.
"""
try:
return _ok(get(GET_NGINX_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read proxy config: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/config — Get the current nginx proxy configuration."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def post_config():
"""Save the nginx proxy configuration.
POST /api/proxy/config
Body:
Any JSON object to merge into the config.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_NGINX_CONFIG, body)
logger.info("Proxy config saved: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("Proxy config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save proxy config: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/config — Save the nginx proxy configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_NGINX_CONFIG, bp, precheck=require_dict_body, transform=void_transform
)
def patch_config():
"""Partially update the nginx proxy configuration.
PATCH /api/proxy/config
Body:
JSON object with fields to patch.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_NGINX_CONFIG, body)
logger.info("Proxy config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("Proxy config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch proxy config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/proxy/config — Partially update the nginx proxy configuration."""
@bp.route("/domains", methods=["GET"])
@daemon_route(GET_NGINX_DOMAINS, bp)
def list_domains():
"""List all configured proxy domains.
GET /api/proxy/domains
Returns:
List of domain dicts from the daemon.
"""
try:
return _ok(get(GET_NGINX_DOMAINS))
except RuntimeError as exc:
logger.error("Failed to list proxy domains: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/domains — List all configured proxy domains."""
@bp.route("/domains", methods=["POST"])
def add_domain_bp():
"""Add a new proxy domain referencing a backend.
# ---------------------------------------------------------------------------
# Domain CRUD
# ---------------------------------------------------------------------------
POST /api/proxy/domains
Body fields:
domain: Domain name.
backend: Backend name to proxy through.
cert: Optional certificate type.
force_ssl: Optional SSL redirect flag (default ``true``).
auth: Optional domain-level auth override.
Returns:
``{"domain": ...}`` on success.
"""
def _add_domain_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
return _error("'domain' is required", 400)
backend = body.get("backend", "").strip()
raise ValueError("'domain' is required")
backend = (body.get("backend") or "").strip()
if not backend:
return _error("'backend' is required", 400)
payload = {
raise ValueError("'backend' is required")
payload: dict[str, Any] = {
"domain": domain,
"backend": backend,
"force_ssl": body.get("force_ssl", True),
@@ -170,105 +91,62 @@ def add_domain_bp():
payload["cert"] = body["cert"]
if body.get("auth") is not None:
payload["auth"] = body["auth"]
try:
post(POST_NGINX_DOMAINS_ADD, payload)
logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain})
except BadRequest as exc:
logger.info("Add proxy domain '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
return _error(str(exc), 500)
return payload
@bp.route("/domains/<domain>", methods=["PUT"])
def update_domain_bp(domain):
"""Update an existing proxy domain in-place.
PUT /api/proxy/domains/<domain>
Body fields:
Fields to merge into the domain config.
Returns:
``{"domain": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not body:
return _error("Request body must be a JSON object with fields to update", 400)
try:
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain})
except BadRequest as exc:
logger.info("Update domain '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Domain '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update domain '%s': %s", domain, exc)
return _error(str(exc), 500)
def _domain_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"domain": sent.get("domain")}
@bp.route("/domains/<domain>", methods=["DELETE"])
def remove_domain_bp(domain):
"""Remove a proxy domain.
DELETE /api/proxy/domains/<domain>
Returns:
``{"domain": ...}`` on success.
"""
try:
delete(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
logger.info("Proxy domain removed via API: %s", domain)
return _ok({"domain": domain})
except NotFound as exc:
logger.info("Domain '%s' not found: %s", domain, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@daemon_route(
POST_NGINX_DOMAINS_ADD,
bp,
rule="/domains",
body=_add_domain_body,
transform=_domain_echo,
)
def add_domain_bp():
"""POST /api/proxy/domains — Add a new proxy domain referencing a backend."""
@bp.route("/apply", methods=["POST"])
def _update_domain_precheck(json: Any, _va: Any) -> None:
if not json:
raise ValueError("Request body must be a JSON object with fields to update")
@daemon_route(
POST_NGINX_DOMAINS_UPDATE,
bp,
rule="/domains/<domain>",
methods=["PUT"],
precheck=_update_domain_precheck,
transform=_domain_echo,
)
def update_domain_bp():
"""PUT /api/proxy/domains/<domain> — Update an existing proxy domain in-place."""
@daemon_route(
DELETE_NGINX_DOMAINS_REMOVE, bp, rule="/domains/<domain>", transform=_domain_echo
)
def remove_domain_bp():
"""DELETE /api/proxy/domains/<domain> — Remove a proxy domain."""
@daemon_route(POST_NGINX_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""Generate all nginx configs and reload nginx.
POST /api/proxy/apply
Returns:
``{"ok": true}`` on success.
"""
try:
post(POST_NGINX_APPLY)
logger.info("nginx config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply nginx config: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/apply — Generate all nginx configs and reload nginx."""
@bp.route("/test", methods=["POST"])
def _test_transform(data: Any, _va: Any, _sent: Any) -> Any:
if data.get("valid"):
return {"valid": True, "output": data.get("output", "")}
raise BadRequest(data.get("output", "unknown error"))
@daemon_route(POST_NGINX_TEST, bp, body=NO_BODY, transform=_test_transform)
def test_bp():
"""Test nginx configuration without reloading.
POST /api/proxy/test
Returns:
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
"""
try:
result = post(POST_NGINX_TEST)
if result.get("valid"):
return _ok({"valid": True, "output": result.get("output", "")})
return _error(result.get("output", "unknown error"), 400)
except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500)
"""POST /api/proxy/test — Test nginx configuration without reloading."""
# ---------------------------------------------------------------------------
@@ -276,102 +154,40 @@ def test_bp():
# ---------------------------------------------------------------------------
@bp.route("/backends", methods=["GET"])
def _backend_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"backend": sent.get("name")}
@daemon_route(GET_NGINX_BACKENDS, bp)
def list_backends():
"""List all configured backends.
GET /api/proxy/backends
Returns:
Dict of backend configs with secrets stripped.
"""
try:
return _ok(get(GET_NGINX_BACKENDS))
except RuntimeError as exc:
logger.error("Failed to list backends: %s", exc)
return _error(str(exc), 500)
"""GET /api/proxy/backends — List all configured backends (secrets stripped)."""
@bp.route("/backends", methods=["PATCH"])
@daemon_route(
PATCH_NGINX_BACKENDS, bp, precheck=require_dict_body, transform=_backend_echo
)
def patch_backend_bp():
"""Partially update a backend entry.
PATCH /api/proxy/backends
Body fields:
name: Backend name.
label: Optional new label.
paths: Optional new paths dict.
auth: Optional new auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_NGINX_BACKENDS, body)
logger.info("Backend '%s' patched via API", body.get("name"))
return _ok({"backend": body.get("name")})
except BadRequest as exc:
logger.info("Backend patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch backend: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/proxy/backends — Partially update a backend entry."""
@bp.route("/backends", methods=["POST"])
def _add_backend_precheck(json: Any, _va: Any) -> None:
if not ((json or {}).get("name") or "").strip():
raise ValueError("'name' is required")
@daemon_route(
POST_NGINX_BACKENDS_ADD,
bp,
rule="/backends",
precheck=_add_backend_precheck,
transform=_backend_echo,
)
def add_backend_bp():
"""Add a new backend.
POST /api/proxy/backends
Body fields:
name: Backend name (slug, unique).
label: Human-readable label.
paths: Path-to-config map.
auth: Optional auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("'name' is required", 400)
try:
post(POST_NGINX_BACKENDS_ADD, body)
logger.info("Backend added via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Add backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add backend '%s': %s", name, exc)
return _error(str(exc), 500)
"""POST /api/proxy/backends — Add a new backend."""
@bp.route("/backends/<name>", methods=["DELETE"])
def remove_backend_bp(name):
"""Remove a non-builtin backend.
DELETE /api/proxy/backends/<name>
Returns:
``{"backend": ...}`` on success.
"""
try:
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
logger.info("Backend removed via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Remove backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Remove backend '%s' conflict: %s", name, exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to remove backend '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_NGINX_BACKENDS_REMOVE, bp, rule="/backends/<name>", transform=_backend_echo
)
def remove_backend_bp():
"""DELETE /api/proxy/backends/<name> — Remove a non-builtin backend."""
+16 -79
View File
@@ -3,13 +3,12 @@
Exposed at /api/status/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
from flask import Blueprint
import logging
from flask import Blueprint, request
from daemon.client import get, post
from daemon.client import ( # noqa: F401 (resolved via module globals at dispatch)
get,
post,
)
from daemon.iface import (
GET_STATUS_PENDING,
GET_SYSTEM_METRICS,
@@ -17,93 +16,31 @@ from daemon.iface import (
POST_STATUS_CANCEL_ALL,
POST_STATUS_REFRESH,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route
logger = logging.getLogger(__name__)
bp = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"])
@daemon_route(GET_STATUS_PENDING, bp)
def pending():
"""Retrieve aggregate pending changes across all subsystems.
Endpoint:
GET /api/status/pending
Returns:
JSON response with per-subsystem pending status and total change count.
"""
try:
return _ok(get(GET_STATUS_PENDING))
except RuntimeError as exc:
logger.error("Failed to get pending status: %s", exc)
return _error(str(exc), 500)
"""GET /api/status/pending — Per-subsystem pending status + total change count."""
@bp.route("/apply-all", methods=["POST"])
@daemon_route(POST_STATUS_APPLY_ALL, bp)
def apply_all():
"""Apply pending changes for all subsystems in dependency order.
Endpoint:
POST /api/status/apply-all
Returns:
JSON response with applied subsystems list and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_APPLY_ALL))
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/apply-all — Apply pending changes in dependency order."""
@bp.route("/cancel-all", methods=["POST"])
@daemon_route(POST_STATUS_CANCEL_ALL, bp, body=NO_BODY)
def cancel_all():
"""Revert pending changes for all subsystems to the last applied config.
Endpoint:
POST /api/status/cancel-all
Returns:
JSON response with the reverted subsystems, skipped subsystems
(label -> reason), and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_CANCEL_ALL))
except RuntimeError as exc:
logger.error("Failed to cancel all pending changes: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/cancel-all — Revert pending changes to last applied config."""
@bp.route("/refresh", methods=["POST"])
@daemon_route(POST_STATUS_REFRESH, bp)
def refresh():
"""Re-collect state from the daemon, optionally filtered by subsystem.
Endpoint:
POST /api/status/refresh
Body:
{"subsystems": ["firewall"]} or {} for all.
"""
body = request.get_json(silent=True) or {}
try:
return _ok(post(POST_STATUS_REFRESH, body))
except RuntimeError as exc:
logger.error("Failed to refresh state: %s", exc)
return _error(str(exc), 500)
"""POST /api/status/refresh — Re-collect state, optionally filtered by subsystem."""
@bp.route("/system-metrics", methods=["GET"])
@daemon_route(GET_SYSTEM_METRICS, bp, rule="/system-metrics")
def system_metrics():
"""Retrieve system-wide metrics.
Endpoint:
GET /api/status/system-metrics
Returns:
JSON response with CPU load, memory usage, and network traffic stats.
"""
try:
return _ok(get(GET_SYSTEM_METRICS))
except RuntimeError as exc:
logger.error("Failed to get system metrics: %s", exc)
return _error(str(exc), 500)
"""GET /api/status/system-metrics — System-wide CPU/memory/network metrics."""
+204 -425
View File
@@ -3,11 +3,16 @@
Exposed at /api/wireguard/* and delegates to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.client import ( # noqa: F401 (resolved via module globals)
delete,
get,
patch,
post,
)
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
@@ -30,208 +35,31 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from webui.api.common import _error, _ok
from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform
logger = logging.getLogger(__name__)
bp = Blueprint("wireguard", __name__)
@bp.route("/config", methods=["GET"])
def get_config_bp():
"""Get the current WireGuard configuration.
Endpoint: GET /api/wireguard/config
Returns:
JSON response with the WireGuard config on success, or an error
response on failure.
"""
try:
return _ok(get(GET_WIREGUARD_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read WireGuard config: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Body builders / prechecks / transforms
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["POST"])
def post_config():
"""Create or fully replace the WireGuard configuration.
Endpoint: POST /api/wireguard/config
Args:
body: JSON body with the configuration. If an ``interface`` key
is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
def _wg_config_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
post(POST_WIREGUARD_CONFIG, body)
return _ok(None)
except BadRequest as exc:
logger.info("WireGuard config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save WireGuard config: %s", exc)
return _error(str(exc), 500)
return body
@bp.route("/config", methods=["PATCH"])
def patch_config():
"""Partially update the WireGuard configuration.
Endpoint: PATCH /api/wireguard/config
Args:
body: JSON body with the fields to update. If an ``interface``
key is present, the private key will be stripped before forwarding.
Returns:
Success response on acceptance, 400 on validation failure, or 500
on server error.
"""
def _add_peer_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
patch(PATCH_WIREGUARD_CONFIG, body)
logger.info("WireGuard config patched: %s", sorted(body.keys()))
return _ok(None)
except BadRequest as exc:
logger.info("WireGuard config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/apply", methods=["POST"])
def apply_bp():
"""Apply the current WireGuard configuration to the live tunnel.
Endpoint: POST /api/wireguard/apply
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/up", methods=["POST"])
def up_bp():
"""Bring the WireGuard tunnel interface up.
Endpoint: POST /api/wireguard/up
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_APPLY)
logger.info("WireGuard tunnel started via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to start WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@bp.route("/down", methods=["POST"])
def down_bp():
"""Bring the WireGuard tunnel interface down.
Endpoint: POST /api/wireguard/down
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_DOWN)
logger.info("WireGuard tunnel brought down via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@bp.route("/status", methods=["GET"])
def status_bp():
"""Get the current WireGuard tunnel status.
Endpoint: GET /api/wireguard/status
Returns:
JSON response with the tunnel status on success, or an error
response on failure.
"""
try:
return _ok(get(GET_WIREGUARD_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard status: %s", exc)
return _error(str(exc), 500)
@bp.route("/initialize", methods=["POST"])
def initialize_bp():
"""Initialize WireGuard for first-time use.
Endpoint: POST /api/wireguard/initialize
Returns:
Success response on acceptance, or 500 on server error.
"""
try:
post(POST_WIREGUARD_INITIALIZE)
logger.info("WireGuard initialized via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to initialize WireGuard: %s", exc)
return _error(str(exc), 500)
@bp.route("/peers", methods=["POST"])
def add_peer_bp():
"""Add a new peer to the WireGuard configuration.
Endpoint: POST /api/wireguard/peers
Args:
name: Peer display name (required).
endpoint: Optional peer endpoint address.
allowed_ips: Optional list of allowed IP CIDRs.
persistent_keepalive: Optional keepalive interval in seconds.
preshared_key: Optional pre-shared key in hex.
Returns:
JSON response with the created peer on success, 400 on validation
failure, or 500 on server error.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
name = (body.get("name") or "").strip()
if not name:
return _error("'name' is required", 400)
try:
peer = post(
POST_WIREGUARD_PEERS_ADD,
{
raise ValueError("'name' is required")
return {
"name": name,
"endpoint": body.get("endpoint"),
"allowed_ips": body.get("allowed_ips", []),
@@ -239,264 +67,215 @@ def add_peer_bp():
"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 remove_peer_bp(name):
"""Remove a peer from the WireGuard configuration.
Endpoint: DELETE /api/wireguard/peers/<name>
Args:
name: Peer name to remove (from URL path).
Returns:
Success response with peer name on removal, 404 if peer not found,
or 500 on server error.
"""
try:
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name})
except NotFound as exc:
logger.info("WireGuard peer '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove peer '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/peers", methods=["GET"])
def peers_bp():
"""List all configured WireGuard peers.
Endpoint: GET /api/wireguard/peers
Returns:
JSON response with the peers list on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEERS))
except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500)
@bp.route("/peer-status", methods=["GET"])
def peer_status_bp():
"""Get real-time status information for all WireGuard peers.
Endpoint: GET /api/wireguard/peer-status
Returns:
JSON response with peer status on success, or an error response
on failure.
"""
try:
return _ok(get(GET_WIREGUARD_PEER_STATUS))
except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500)
@bp.route("/generate-client", methods=["POST"])
def generate_client_bp():
"""Generate a WireGuard client configuration file for a peer.
Endpoint: POST /api/wireguard/generate-client
Args:
name: Peer name (required).
server_endpoint: Server endpoint address for the client config (required).
Returns:
JSON response with the generated config string on success, 404 if
peer not found, 400 on validation failure, or 500 on server error.
"""
def _gen_client_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
name = (body.get("name") or "").strip()
if not name:
return _error("Field 'name' is required", 400)
raise ValueError("Field 'name' is required")
server_endpoint = body.get("server_endpoint", "")
if not server_endpoint:
return _error("Field 'server_endpoint' is required", 400)
try:
result = post(
POST_WIREGUARD_GENERATE_CLIENT,
{
"name": name,
"server_endpoint": server_endpoint,
},
)
logger.info("Client config generated for peer '%s' via API", name)
return _ok({"config": result.get("config", "")})
except NotFound as exc:
logger.info("Peer '%s' not found for client config: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), 500)
raise ValueError("Field 'server_endpoint' is required")
return {"name": name, "server_endpoint": server_endpoint}
@bp.route("/classes", methods=["GET"])
def _delete_class_body(request: Any, _va: Any) -> dict[str, Any]:
body = request.get_json(silent=True) or {}
key = (body.get("key") or "").strip()
if not key:
raise ValueError("'key' is required")
return {"key": key}
def _class_key_precheck(json: Any, va: dict[str, Any]) -> None:
require_dict_body(json, va)
if not ((json or {}).get("key") or "").strip():
raise ValueError("'key' is required")
def _peer_name_echo(_data: Any, _va: Any, sent: Any) -> Any:
return {"name": sent["name"]}
def _config_echo(data: Any, _va: Any, _sent: Any) -> Any:
return {"config": data.get("config", "")}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CONFIG, bp)
def get_config_bp():
"""GET /api/wireguard/config — Get the current WireGuard configuration."""
@daemon_route(
POST_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def post_config():
"""POST /api/wireguard/config — Create or fully replace the configuration."""
@daemon_route(
PATCH_WIREGUARD_CONFIG,
bp,
precheck=require_dict_body,
body=_wg_config_body,
transform=void_transform,
)
def patch_config():
"""PATCH /api/wireguard/config — Partially update the configuration."""
# ---------------------------------------------------------------------------
# Tunnel control
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_APPLY, bp, body=NO_BODY, transform=void_transform)
def apply_bp():
"""POST /api/wireguard/apply — Apply the current configuration to the tunnel."""
@daemon_route(
POST_WIREGUARD_APPLY, bp, rule="/up", body=NO_BODY, transform=void_transform
)
def up_bp():
"""POST /api/wireguard/up — Bring the WireGuard tunnel interface up."""
@daemon_route(POST_WIREGUARD_DOWN, bp, body=NO_BODY, transform=void_transform)
def down_bp():
"""POST /api/wireguard/down — Bring the WireGuard tunnel interface down."""
@daemon_route(GET_WIREGUARD_STATUS, bp)
def status_bp():
"""GET /api/wireguard/status — Get the current WireGuard tunnel status."""
@daemon_route(POST_WIREGUARD_INITIALIZE, bp, body=NO_BODY, transform=void_transform)
def initialize_bp():
"""POST /api/wireguard/initialize — Initialize WireGuard for first-time use."""
# ---------------------------------------------------------------------------
# Peers
# ---------------------------------------------------------------------------
@daemon_route(POST_WIREGUARD_PEERS_ADD, bp, rule="/peers", body=_add_peer_body)
def add_peer_bp():
"""POST /api/wireguard/peers — Add a new peer to the configuration."""
@daemon_route(
DELETE_WIREGUARD_PEERS_REMOVE, bp, rule="/peers/<name>", transform=_peer_name_echo
)
def remove_peer_bp():
"""DELETE /api/wireguard/peers/<name> — Remove a peer from the configuration."""
@daemon_route(GET_WIREGUARD_PEERS, bp)
def peers_bp():
"""GET /api/wireguard/peers — List all configured WireGuard peers."""
@daemon_route(GET_WIREGUARD_PEER_STATUS, bp)
def peer_status_bp():
"""GET /api/wireguard/peer-status — Get real-time status for all peers."""
@daemon_route(
POST_WIREGUARD_GENERATE_CLIENT, bp, body=_gen_client_body, transform=_config_echo
)
def generate_client_bp():
"""POST /api/wireguard/generate-client — Generate a client config for a peer."""
# ---------------------------------------------------------------------------
# Access classes
# ---------------------------------------------------------------------------
@daemon_route(GET_WIREGUARD_CLASSES, bp)
def list_classes_bp():
"""List all access classes.
Endpoint: GET /api/wireguard/classes
"""
try:
return _ok(get(GET_WIREGUARD_CLASSES))
except RuntimeError as exc:
logger.error("Failed to list access classes: %s", exc)
return _error(str(exc), 500)
"""GET /api/wireguard/classes — List all access classes."""
@bp.route("/classes", methods=["POST"])
@daemon_route(POST_WIREGUARD_CLASSES, bp, precheck=_class_key_precheck)
def create_class_bp():
"""Create a new access class.
Endpoint: POST /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = post(POST_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Create access class rejected: %s", exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Create access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to create access class: %s", exc)
return _error(str(exc), 500)
"""POST /api/wireguard/classes — Create a new access class."""
@bp.route("/classes", methods=["PATCH"])
@daemon_route(
PATCH_WIREGUARD_CLASSES, bp, rule="/classes", precheck=_class_key_precheck
)
def update_class_bp():
"""Update an access class.
Endpoint: PATCH /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = patch(PATCH_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Update access class rejected: %s", exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update access class: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/wireguard/classes — Update an access class."""
@bp.route("/classes", methods=["DELETE"])
@daemon_route(
DELETE_WIREGUARD_CLASSES,
bp,
rule="/classes",
precheck=require_dict_body,
body=_delete_class_body,
)
def delete_class_bp():
"""Delete an access class.
Endpoint: DELETE /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
logger.info("Access class '%s' deleted via API", key)
return _ok(result)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("Delete access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to delete access class: %s", exc)
return _error(str(exc), 500)
"""DELETE /api/wireguard/classes — Delete an access class."""
@bp.route("/classes/<key>/up", methods=["POST"])
def class_up_bp(key):
"""Bring up a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/up
"""
try:
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought up via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
POST_WIREGUARD_CLASSES_UP,
bp,
rule="/classes/<key>/up",
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_up_bp():
"""POST /api/wireguard/classes/<key>/up — Bring up a class's tunnel."""
@bp.route("/classes/<key>/down", methods=["POST"])
def class_down_bp(key):
"""Bring down a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/down
"""
try:
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought down via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_WIREGUARD_CLASSES_DOWN,
bp,
rule="/classes/<key>/down",
methods=["POST"],
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_down_bp():
"""POST /api/wireguard/classes/<key>/down — Bring down a class's tunnel."""
@bp.route("/classes/<key>/status", methods=["GET"])
def class_status_bp(key):
"""Get status for a single access class's tunnel.
Endpoint: GET /api/wireguard/classes/<key>/status
"""
try:
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
GET_WIREGUARD_CLASS_STATUS,
bp,
rule="/classes/<key>/status",
params={"class_key": "key"},
)
def class_status_bp():
"""GET /api/wireguard/classes/<key>/status — Get status for a class's tunnel."""
@bp.route("/classes/keys/<key>", methods=["POST"])
def class_init_keys_bp(key):
"""Generate key pair for a single access class.
Endpoint: POST /api/wireguard/classes/keys/<key>
"""
try:
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
logger.info("WireGuard class '%s' keys generated via API", key)
return _ok(None)
except NotFound as exc:
logger.info("Class '%s' not found for keys: %s", key, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate keys for class '%s': %s", key, exc)
return _error(str(exc), 500)
@daemon_route(
POST_WIREGUARD_CLASS_INIT_KEYS,
bp,
rule="/classes/keys/<key>",
params={"class_key": "key"},
body={},
transform=void_transform,
)
def class_init_keys_bp():
"""POST /api/wireguard/classes/keys/<key> — Generate keys for a class."""
+38 -4
View File
@@ -107,19 +107,53 @@ export const _toasts = [];
const _toastIds = { next: 1 };
/**
* Show a toast notification. Auto-dismisses after `duration` ms.
* Default auto-dismiss durations per toast type (ms). 0 = never
* auto-dismiss. Errors stay on screen until dismissed so long
* failure messages remain readable.
*/
const _TOAST_DEFAULT_DURATIONS = {
info: 4000,
success: 4000,
warning: 8000,
error: 0,
};
/**
* Auto-dismiss timer that pauses while the toast is hovered.
* Re-checks in 1s while hovered instead of dismissing.
*/
function _scheduleToastDismiss(id, delay) {
setTimeout(() => {
const t = _toasts.find(t => t.id === id);
if (!t) return;
if (t.hovered) _scheduleToastDismiss(id, 1000);
else dismissToast(id);
}, delay);
}
/**
* Show a toast notification.
*
* When `duration` is omitted, per-type defaults apply: 'info' and
* 'success' auto-dismiss after 4000 ms, 'warning' after 8000 ms, and
* 'error' toasts never auto-dismiss. An explicit `duration` overrides
* the default. The auto-dismiss timer pauses while the toast is
* hovered.
*
* @param {string} message Toast text
* @param {string} [type] 'info' | 'success' | 'error' | 'warning'
* @param {number} [duration] Auto-dismiss timeout in ms (0 = indefinite)
* @returns {number} id
*/
export function toast(message, type = 'info', duration = 4000) {
export function toast(message, type = 'info', duration) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
const dur = duration === undefined
? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000)
: duration;
_toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false });
requestUpdate();
if (duration > 0) setTimeout(() => dismissToast(id), duration);
if (dur > 0) _scheduleToastDismiss(id, dur);
return id;
}
+51 -4
View File
@@ -54,17 +54,54 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
return vnodeList;
}
/**
* Decide the toasts for an apply-all response payload.
*
* The endpoint returns 200 with `{ applied, errors }` even when some
* subsystems failed (e.g. the firewall safety guards refused a change), so
* `resp.ok` alone is not a success signal. An error always wins: when any
* subsystem failed, report it and suppress the success toast.
*
* @param {object} data Response payload `{ applied, errors }`
* @param {string} [successMsg] Message for the success toast
* @returns {{error: string|null, success: string|null}}
*/
export function applyResultToasts(data, successMsg) {
const errs = (data && data.errors) || {};
const entries = Object.entries(errs);
if (entries.length) {
return {
error: 'Apply failed for: ' +
entries.map(([k, v]) => `${k}${v}`).join('; '),
success: null,
};
}
const applied = (data && data.applied) || [];
return {
error: null,
success: applied.length ? successMsg : null,
};
}
/**
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*
* @param {string} successMsg Success toast message
* @param {boolean} [force] Forward `{"force": true}` to override the
* firewall safety guards
*/
async function doApply(successMsg) {
async function doApply(successMsg, force) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
const opts = { method: 'POST' };
if (force) opts.body = { force: true };
const resp = await apiFetch('/api/status/apply-all', opts);
if (resp.ok) {
toast(successMsg, 'success');
const t = applyResultToasts(resp.data, successMsg);
if (t.error) toast(t.error, 'error', 8000);
else if (t.success) toast(t.success, 'success');
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
@@ -90,6 +127,12 @@ async function openApplyModal(successMsg) {
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
// Only meaningful when the firewall has pending changes (the only
// subsystem whose apply honours `force`); the checkbox tracks its own
// DOM state — no reactivity needed.
const fwPending = isPending(pendingData.firewall) &&
((pendingData.firewall.changes || []).length > 0);
let force = false;
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
@@ -106,7 +149,11 @@ async function openApplyModal(successMsg) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
<span class="text-sm">Force apply <span class="text-muted"> overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
</label>` : ''}
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
</div>`);
});
}
+28 -4
View File
@@ -37,6 +37,13 @@ export function StatusDot(props = {}) {
return h('span', { class: `status-dot status-${v}` });
}
/**
* Small amber dot marking a pending (edited, not yet applied) element.
*/
export function PendingDot() {
return h('span', { class: 'pending-dot' });
}
/**
* Empty-state placeholder card.
*
@@ -55,16 +62,20 @@ export function Empty(props = {}) {
* @param {object} props
* @param {string} [props.header]
* @param {VNode[]} [props.children]
* @param {string} [props.cls] - Extra class appended to the outer `div.card`
* @param {string} [props.title] - Tooltip on the outer `div.card`
*/
export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
const title = props.title ? { title: props.title } : {};
if (props.header) {
return h('div', { class: 'card', ...key },
return h('div', { class: cls, ...title, ...key },
h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []),
);
}
return h('div', { class: 'card', ...key }, props.children || []);
return h('div', { class: cls, ...title, ...key }, props.children || []);
}
/**
@@ -171,6 +182,14 @@ export function ActionButton(props = {}) {
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
// Batch endpoints (e.g. /api/status/apply-all) return 200
// with an `errors` map when some operations failed —
// `resp.ok` alone is not a success signal.
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
const errEntries = errs ? Object.entries(errs) : [];
if (errEntries.length) {
toast('Failed: ' + errEntries.map(([k, v]) => `${k}${v}`).join('; '), 'error', 8000);
} else {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
@@ -178,6 +197,7 @@ export function ActionButton(props = {}) {
msg += '(auto-synced: ' + synced.join(', ') + ')';
}
if (msg) toast(msg, 'success');
}
if (props.onSuccess) props.onSuccess();
// No modelFetch — WS delta updates state store models.
} else {
@@ -200,6 +220,8 @@ export function ActionButton(props = {}) {
* @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key
* @param {string} [props.cls] - Extra class appended to the wrapper (or div.card)
* @param {string} [props.title] - Tooltip on the wrapper element
*/
export function Table(props = {}) {
const cols = props.columns || [];
@@ -216,10 +238,12 @@ export function Table(props = {}) {
),
);
const key = props.key !== undefined ? { key: props.key } : {};
const title = props.title ? { title: props.title } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table);
return h('div', { class: cls, ...title, ...key }, table);
}
return h('div', key, table);
return h('div', { ...title, ...key }, table);
}
/**
+41 -6
View File
@@ -3,10 +3,38 @@
*
* ToastContainer component that renders queued toast notifications.
* Uses the toast/dismissToast state from api.js.
*
* Long messages (>200 chars or containing newlines) render compact
* first line with an ellipsis plus a "Details" button that opens a
* modal with the full text. Dismissal is only via the × button;
* hovering the toast pauses its auto-dismiss timer.
*/
import { h } from '../vdom.js';
import { _toasts, dismissToast } from '../api.js';
import { openModal } from './modal.js';
/** Messages longer than this (or containing newlines) render compact. */
const _LONG_MESSAGE_CHARS = 200;
function _isLong(message) {
return message.length > _LONG_MESSAGE_CHARS || message.includes('\n');
}
function _firstLine(message) {
return message.split('\n')[0].trim();
}
function _showDetails(t) {
openModal(
h('div', null,
h('h2', { class: 'modal-title' }, 'Details'),
h('div', { class: 'modal-body' },
h('pre', { class: 'toast-details-msg' }, t.message),
),
),
);
}
/**
* Render all pending toast notifications.
@@ -24,19 +52,26 @@ export function ToastContainer() {
};
return h('div', { class: 'toast' },
..._toasts.map(t =>
h('div', {
..._toasts.map(t => {
const long = _isLong(t.message);
return h('div', {
class: `toast-message ${clsMap[t.type] || clsMap.info}`,
'on:click': () => dismissToast(t.id),
'on:mouseover': () => { t.hovered = true; },
'on:mouseout': () => { t.hovered = false; },
},
h('span', { class: 'toast-text' }, t.message),
h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') },
long ? _firstLine(t.message) : t.message),
h('div', { class: 'toast-actions' },
long ? h('button', {
class: 'toast-btn toast-details',
'on:click': (e) => { e.stopPropagation(); _showDetails(t); },
}, 'Details') : null,
h('button', {
class: 'toast-btn toast-close',
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
}, '\u00d7'),
),
),
),
);
}),
);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Hoover dirty.js
*
* Marks UI elements that have been edited (saved to config) but not yet
* applied to the live system. Consumes the daemon-provided pending state:
* - hash subsystems: status.pending_diff -> [{path, action, old, new}]
* - firewall: pending -> {needs_apply, pending:[{zone,type,...}]}
*
* "Line" matching: an element path is dirty when it shares a root-to-leaf line
* with a pending path equal, an ancestor, or a descendant. A plain key is a
* prefix of its indexed form, so a whole-list change (e.g. `dhcp.ranges`)
* marks every row, while a leaf change (`interface.listen_port`) marks only
* that field/row.
*/
function segs(p) {
return p ? String(p).split('.').filter(Boolean) : [];
}
// Is segment `a` a prefix of segment `b`? "ranges" prefixes "ranges[0]"
// (the trailing bracket keeps "ranges[1" from prefixing "ranges[12]").
function segPrefix(a, b) {
return a === b || b.indexOf(a + '[') === 0;
}
// Are paths p and q on the same root-to-leaf line? Segment matching is
// bidirectional so both directions of containment hold: a pending leaf under
// the element (`dhcp.ranges` vs `dhcp.ranges[0].start`) and a pending
// container over the element (`dhcp.ranges[3]` vs `dhcp.ranges`).
function isLine(p, q) {
const A = segs(p);
const B = segs(q);
if (!A.length || !B.length) return false;
const n = Math.min(A.length, B.length);
for (let i = 0; i < n; i++) {
if (!segPrefix(A[i], B[i]) && !segPrefix(B[i], A[i])) return false;
}
return true;
}
/**
* Sentinel path marking "pending but no diff baseline": the config was
* saved but never applied, so the daemon has no applied snapshot to diff
* against and `pending_diff` is empty while `pending_changes` is true.
* Every element is dirty in this case.
*/
const ANY_PATH = Symbol('dirty: any');
/** Set of pending config paths from a hash-subsystem status object. */
export function dirtySet(status) {
const diff = status && Array.isArray(status.pending_diff) ? status.pending_diff : [];
const s = new Set();
for (const d of diff) {
if (d && d.path) s.add(String(d.path));
}
if (!s.size && status && status.pending_changes) s.add(ANY_PATH);
return s;
}
/** True when element path `path` is (under / above / equal to) a pending change. */
export function isDirty(set, path) {
if (!set || !set.size) return false;
if (set.has(ANY_PATH)) return true;
const p = String(path || '');
for (const q of set) {
if (isLine(p, q)) return true;
}
return false;
}
/** Tooltip listing the concrete pending field(s) that affect `path`. */
export function dirtyTitle(set, path) {
if (!set || !set.size) return '';
if (set.has(ANY_PATH)) return 'Configuration saved but not applied yet';
const p = String(path || '');
const hits = [...set].filter((q) => isLine(p, q)).sort();
if (!hits.length) return '';
return 'Unapplied changes: ' + hits.join(', ');
}
/** One object for a hash-subsystem element. Use class/title on the element. */
export function dirtyInfo(set, path) {
const dirty = isDirty(set, path);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? dirtyTitle(set, path) : '',
};
}
const CLEAN_INFO = { dirty: false, class: '', title: '' };
/**
* One object for a container element, covering pending changes under `root`
* that no longer have a live child element to mark: a removed dict key
* (e.g. `peers.p1`) leaves its pending path with no row/section for a
* per-element marker to attach to. `children` is the list of element paths
* for the container's live children (e.g. `'peers.' + name` per configured
* peer). Clean when there are no such orphaned paths, when the set is the
* never-applied sentinel (every element is already marked), or when the root
* itself is pending (every child row is marked instead).
*/
export function orphanInfo(set, root, children) {
if (!set || !set.size || set.has(ANY_PATH)) return CLEAN_INFO;
const r = String(root || '');
const childList = (children || []).map(String);
const hits = [];
for (const q of set) {
if (q === r || !isLine(r, q)) continue;
if (!childList.some((cp) => isLine(q, cp))) hits.push(q);
}
if (!hits.length) return CLEAN_INFO;
return {
dirty: true,
class: 'config-dirty',
title: 'Unapplied changes: ' + hits.sort().join(', '),
};
}
// ── Firewall (zone + type granularity) ─────────────────────────
/** Map<zone, Set<type>> from a firewall pending object. */
export function fwDirty(pending) {
const m = new Map();
const list = pending && Array.isArray(pending.pending) ? pending.pending : [];
for (const c of list) {
if (!c || !c.zone) continue;
if (!m.has(c.zone)) m.set(c.zone, new Set());
if (c.type) m.get(c.zone).add(c.type);
}
return m;
}
/** True when `zone` (and optionally `type`) has a pending firewall change. */
export function fwIsDirty(map, zone, type) {
if (!map || !map.size) return false;
const types = map.get(zone);
if (!types) return false;
if (type) return types.has(type);
return true;
}
/** Tooltip for a firewall zone (and optional type). */
export function fwTitle(map, zone, type) {
const types = map.get(zone);
if (!types) return '';
const t = [...types].sort();
const shown = type ? t.filter((x) => x === type) : t;
if (!shown.length) return '';
return 'Unapplied changes: ' + shown.join(', ');
}
/** One object for a firewall element (zone, optional type). */
export function fwInfo(map, zone, type) {
const dirty = fwIsDirty(map, zone, type);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? fwTitle(map, zone, type) : '',
};
}
+4 -1
View File
@@ -47,7 +47,7 @@ export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob }
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
/* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js';
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect, PendingDot } from './components/data.js';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
@@ -60,3 +60,6 @@ export { ToastContainer } from './components/toast.js';
/* ── UI Components: QR Code ──────────────────────────────────── */
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js';
/* ── Dirty / pending-edit markers ─────────────────────────────── */
export { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from './dirty.js';
+11 -7
View File
@@ -1,4 +1,4 @@
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js';
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
import { _deleting } from '/static/hoover/components/data.js';
@@ -260,18 +260,22 @@ export default definePage({
return {
backends: getModel('backends'),
dnsmasq: getModel('dnsmasq'),
nginx: getModel('nginx'),
};
},
render(state) {
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const backends = state.backends.data || {};
const entries = Object.entries(backends);
const rows = entries.map(([name, b]) =>
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
<td><strong>${esc(name)}</strong></td>
const rows = entries.map(([name, b]) => {
const info = dirtyInfo(set, 'backends.' + name);
const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td>
<td>
@@ -291,11 +295,11 @@ export default definePage({
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
onComplete=${() => modelFetch('backends')}
label="Delete" />`
label="Delete" />`}
}
</td>
</tr>`
);
</tr>`;
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
+10
View File
@@ -336,6 +336,7 @@ export default definePage({
if (guard) return guard;
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
const certError = state.acme.data?.status?.error;
const rows = (state.acme.data?.certs || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
@@ -363,6 +364,15 @@ export default definePage({
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
}),
_accountCard(account),
certError
? html`<div class="card">
<div class="card-body">
<div class="text-warning text-sm">
Certificate data unavailable: ${esc(certError)}
</div>
</div>
</div>`
: null,
rows.length
? Table({
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
+2 -2
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ApplyConfirm, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
// Render a single firewall change as "current → new".
// `live` is the currently applied value; `config` is the target value it
@@ -158,7 +158,7 @@ export default definePage({
</li>`)}
</ul>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
<${ApplyConfirm} pending=${true} label="Apply All Changes"
successMsg="All changes applied"
cls="btn btn-sm btn-primary" />
<${CancelConfirm} cls="btn btn-sm btn-danger" />
+22 -11
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
function makeAddRange(activeZones, interfaces) {
const opts = [
@@ -118,6 +118,7 @@ export default definePage({
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
if (guard) return guard;
const set = dirtySet(state.dnsmasq.data?.status);
const cfg = state.dnsmasq.data?.config || {};
const dhcpCfg = cfg.dhcp || {};
const dnsCfg = cfg.dns || {};
@@ -126,8 +127,10 @@ export default definePage({
const dnsRecords = dnsCfg.custom_records || [];
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
<td>${r.interface || '(global)'}</td>
const rangesRows = ranges.map((r, i) => {
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'}</td>
<td>${esc(r.start)}</td>
<td>${esc(r.end)}</td>
<td>${esc(r.lease_time || '12h')}</td>
@@ -139,10 +142,13 @@ export default definePage({
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed" />
</td>
</tr>`);
</tr>`;
});
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
<td>${esc(l.mac)}</td>
const leaseRows = staticLeases.map((l, i) => {
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${esc(l.mac)}</td>
<td>${esc(l.ip)}</td>
<td>${l.hostname || '-'}</td>
<td>
@@ -152,10 +158,13 @@ export default definePage({
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed" />
</td>
</tr>`);
</tr>`;
});
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
const dnsRows = dnsRecords.map((rec, i) => {
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(rec.name || 'unnamed')}</strong></td>
<td class="text-sm">${esc(rec.address || '-')}</td>
<td>
<${ConfirmDelete}
@@ -164,7 +173,8 @@ export default definePage({
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed" />
</td>
</tr>`);
</tr>`;
});
const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', {
@@ -180,7 +190,8 @@ export default definePage({
};
const currentDomain = dnsCfg.domain || null;
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
const domainInfo = dirtyInfo(set, 'dns.domain');
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} style="margin-bottom: 1rem;">
<label style="font-weight: 600;">Search Domain</label>
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
+8 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -45,6 +45,7 @@ export default definePage({
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
const set = dirtySet(state.network.data?.status);
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = Object.keys(fwZones);
@@ -72,9 +73,10 @@ export default definePage({
};
});
const rows = ifaces.map(iface =>
html`<tr key=${iface.name}>
<td><strong>${iface.name}</strong></td>
const rows = ifaces.map(iface => {
const info = dirtyInfo(set, 'interfaces.' + iface.name);
return html`<tr key=${iface.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${iface.name}</strong></td>
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
<td><${StatusText} status=${iface.state} /></td>
@@ -84,8 +86,8 @@ export default definePage({
<button class="btn btn-sm btn-outline" style="margin-left:8px"
onClick=${() => cfgModalFn(iface)}>Config</button>
</td>
</tr>`
);
</tr>`;
});
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
+8 -5
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -33,6 +33,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
@@ -82,8 +83,9 @@ export default definePage({
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
</tr>`;
}
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
const info = fwInfo(fw, zone, 'masquerade');
return html`<tr key=${'m-' + zone} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
@@ -102,8 +104,9 @@ export default definePage({
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
<td><strong>${zone}</strong></td>
const info = fwInfo(fw, zone, 'forward_ports');
fwRows.push(html`<tr key=${'f-' + zone + '-' + i} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
+11 -8
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) {
@@ -172,8 +172,9 @@ function editDomain(d, state) {
modal({});
}
function domainRow(domainName, domainPaths, state) {
function domainRow(domainName, domainPaths, state, set) {
const d = domainPaths[0];
const info = dirtyInfo(set, 'domains.' + domainName);
const certMap = certLookup(state.acme ? state.acme.data : null);
const cert = certMap[d.domain];
let certBadge, certTitle;
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
return html`<tr key=${domainName} class="domain-row ${info.class}" title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(domainName)}</strong></td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
</tr>`;
}
function backendSection(section, state) {
function backendSection(section, state, set) {
const { backendName, backend, domains } = section;
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
const info = dirtyInfo(set, 'backends.' + backendName);
const rows = domains.map(d => domainRow(d.domain, d.paths, state, set));
const sectionActions = [];
if (!backend.builtin) {
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
@@ -233,7 +235,7 @@ function backendSection(section, state) {
}
}
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
return html`<div class="backend-section ${info.class}" title=${info.title || undefined} key=${backendName} style="margin-bottom:24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
<${Badge} text=${esc(backendName)} variant="primary" />
@@ -258,10 +260,11 @@ export default definePage({
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends);
const sectionVNodes = sections.map(s => backendSection(s, state));
const sectionVNodes = sections.map(s => backendSection(s, state, set));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
+8 -2
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -24,6 +24,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {};
@@ -34,6 +35,7 @@ export default definePage({
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
const info = fwInfo(fw, zone, 'rich_rules');
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
@@ -50,8 +52,12 @@ export default definePage({
</tr>`;
});
return Card({
header: 'Zone: ' + esc(zone),
header: info.dirty
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
: 'Zone: ' + esc(zone),
key: zone,
cls: info.class || undefined,
title: info.title || undefined,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: ruleRows,
+19 -7
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
/* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() {
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
}
const classStatuses = status?.classes || {};
const set = dirtySet(status);
const rows = entries.map(([k, v]) => {
const pCount = peerCountMap[k] || 0;
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
const isUp = clsStatus.up;
const hasKeys = classHasKeys(v);
const color = classColor(k);
return html`<tr key=${k}>
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<tr key=${k} class=${info.class || undefined} title=${info.title || undefined}>
<td style="border-left: 3px solid ${color}">${info.dirty ? PendingDot({}) : ''}<strong>${esc(k)}</strong></td>
<td>${esc(v.name || k)}</td>
<td class="text-sm">${esc(v.description || '-')}</td>
<td class="text-sm">${esc(v.subnet || '-')}</td>
@@ -418,12 +420,16 @@ export default definePage({
const wgData = state.wireguard.data;
const st = wgData?.status || {};
const config = wgData?.config || {};
const set = dirtySet(st);
const isUp = st.up || false;
const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || '';
// Build merged peer rows: configured peers + live status
const configuredPeers = wgData?.peers || [];
// A removed peer leaves a `peers.<name>` pending path with no live row
// to attach a per-row marker to; surface it on the table itself.
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
const statusPeersMap = {};
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
for (const sp of (cSt.peers || [])) {
@@ -442,9 +448,11 @@ export default definePage({
const isConnected = sp && !!sp.latest_handshake;
const accessClass = p.access_class;
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
return html`<tr key=${p.name}${borderColor}>
const info = dirtyInfo(set, 'peers.' + p.name);
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
return html`<tr key=${p.name} class=${info.class || undefined} title=${info.title || undefined} style=${style}>
<td>
${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong>
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
@@ -480,7 +488,8 @@ export default definePage({
const isUp = cSt.up;
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
const color = classColor(k);
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<div key=${k} class="card ${info.class}" title=${info.title || undefined} style="border-left: 3px solid ${color}">
<div class="card-header d-flex justify-content-between align-items-center">
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
@@ -502,9 +511,10 @@ export default definePage({
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
}
const ifaceInfo = dirtyInfo(set, 'interface');
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title=${'Interface Settings' + (ifaceInfo.dirty ? ' — ' + ifaceInfo.title : '')}>${ifaceInfo.dirty ? PendingDot({}) : ''}\u{1F527}</button>`,
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
@@ -531,6 +541,8 @@ export default definePage({
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
cls: peersOrphan.class || undefined,
title: peersOrphan.title || undefined,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st),
+12 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
// Services shown by default in the service picker. Everything else is only
// visible with the "Show all options" toggle (or while it is already
@@ -33,6 +33,8 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
// Live zone data (parsed `--list-all-zones`): carries interfaces,
// services, target, and masquerade for every defined zone.
const liveZones = state.firewall.data?.zones || {};
@@ -45,22 +47,26 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return html`<div class="card" key=${name} style="position:relative">
const info = fwInfo(fw, name);
const ifTitle = fwTitle(fw, name, 'interfaces');
const svcTitle = fwTitle(fw, name, 'services');
const tgtTitle = fwTitle(fw, name, 'target');
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px">
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${ifTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`}
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${svcTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
+62
View File
@@ -380,6 +380,37 @@ body {
white-space: pre-wrap;
}
/* Compact rendering for long messages: single line, ellipsized. */
.toast-message .toast-text-long {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-message .toast-details {
font-size: 12px;
text-decoration: underline;
text-underline-offset: 2px;
padding: 0 4px;
}
/* Full message inside the toast Details modal. */
.toast-details-msg {
font-family: monospace;
font-size: 0.8rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 60vh;
overflow-y: auto;
color: var(--text);
}
.toast-message .toast-actions {
display: flex;
gap: 2px;
@@ -1062,6 +1093,37 @@ body {
color: var(--text-muted);
}
/* Pending (edited, not yet applied) marker amber, distinct from the red
.pending-delete. Applied to rows/cards/sections whose config is dirty. */
.config-dirty {
background: rgba(243, 156, 18, 0.07);
}
tr.config-dirty > td:first-child {
box-shadow: inset 3px 0 0 var(--warning);
}
/* A row queued for deletion (red) takes precedence over the dirty marker. */
tr.pending-delete.config-dirty > td:first-child {
box-shadow: none;
}
.card.config-dirty,
.backend-section.config-dirty,
.domain-config.config-dirty {
box-shadow: inset 3px 0 0 var(--warning);
}
.pending-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning);
margin-right: 6px;
vertical-align: middle;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {