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

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

Tests: 917 passed; ruff check + format clean.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+29 -7
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 |
@@ -1983,8 +1993,8 @@ Apply pending changes for all subsystems in dependency order.
```
`force` is forwarded to the firewall apply only — it overrides the
management-lockout and interface-coverage guards. Other subsystems
ignore it.
management-lockout guard and the interface-coverage invariant. Other
subsystems ignore it.
**Response (`data`):**
@@ -1996,10 +2006,14 @@ ignore it.
The endpoint returns `200` even when some subsystems failed — per-subsystem
failures are reported in `errors`, so clients must check `errors` (not just
the HTTP status) before reporting success. Without `force`, the firewall
apply refuses if an interface would be left without zone coverage (the
coverage guard) or both https/ssh would be stripped from the default zone
(lockout guard); the `ConflictError` surfaces in `errors` under
`"Firewall"` while the other subsystems proceed.
apply is refused when a network-managed interface has no zone coverage in
the config and is not `unmanaged` (the interface-coverage invariant) or when
the config would strip both https/ssh from the default zone (lockout guard);
the `ConflictError` surfaces in `errors` under `"Firewall"` while the other
subsystems proceed. Pending state comes from
the last state poll (firewall 30s, dnsmasq 10s, nginx 60s, wireguard 10s,
networkd 10s), so an edit saved within the last poll interval may not be
picked up by this call.
---
@@ -2016,6 +2030,14 @@ Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
Notes: pending state comes from the last state poll (firewall 30s,
dnsmasq 10s, nginx 60s, wireguard 10s, networkd 10s), so an edit saved
within the last poll interval is not yet flagged pending and is left in
place. For the firewall, pending is a config-vs-live diff: cancel
restores only the config file, so live firewalld drift made outside the
declarative config (manual `firewall-cmd`) is not reverted and the
firewall may still report pending after a cancel.
**Request Body:** none.
**Response (`data`):**
+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.
+15 -8
View File
@@ -24,10 +24,14 @@ return annotation references them.
differs from the recorded hash; `status.pending_diff` lists the field
changes since that snapshot. All apply operations (including firewall
`config_apply`) re-stamp the baseline. These bookkeeping keys are
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset.
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset. Apply-all and
cancel-all both decide from this last-poll state (an edit saved within
the last poll interval may not yet be flagged), and cancel reverts only
the declarative config file — live drift (e.g. manual `firewall-cmd`)
survives a cancel.
## State shape summary
@@ -62,10 +66,13 @@ Top-level `FirewallState`:
// firewalld service XML definitions
// (lib/firewall.py get_service_descriptions,
// cached per process)
uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any live zone —
// advisory coverage warning; empty =
// fully covered; NOT counted in pending
uncovered_interfaces: [str], // network-config interfaces (excluding
// lo/wg*) not in any LIVE zone — a
// live-drift advisory (config may still
// cover them); distinct from the
// config-based interface-coverage
// invariant (docs/config.md); NOT
// counted in pending
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",