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`):**