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:
+29
-7
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user