Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
20 KiB
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 whosetargetnormalizes todefault(e.g. the legacy explicit"DEFAULT") is treated as unmanaged in the pending diff as well — it mirrors_config_apply, which only ever setsACCEPT/DROP/REJECT. No backward compatibility is needed. - Coverage guard: ConflictError +
forcein_config_apply; UI confirm in the interfaces picker when an interface's last zone is dropped (zone deletion is out of scope —delete_zonestays unguarded). - Guard scope: explicit
lo/wg*filter — guarded ifaces = network-config keys minusloandwg*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_configso cancel-all baselines stay consistent).PATCHcannot be used:deep_merge(lib/common.py:244-256) has no key-removal path, so the currentpatch_configendpoint cannot delete thetargetkeys (anullvalue 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"forinternalin 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-zonestep is missing and it runsfirewall-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_zonedoes not). The current firewalld man page (verified via live docs 2026-08-28) listsdefaultas an accepted--set-targetvalue 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 nointerfaceskey — no field drift is ever reported for such zones.- The explicit
"target": "DEFAULT"entries inconfig/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 atargetkey for every imported zone with interfaces, and_live_target_to_configmaps livedefaultto the config token"DEFAULT"(lib/firewall.py:353-361). No other code path writestargetinto 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.
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"]withloandwg*prefixes explicitly filtered (vpn zones are managed byWgToFirewallSync;lois normally zoneless — without the filter a networkd-managedlowould make every apply raise). If any guarded interface would be uncovered →ConflictErrornaming the interfaces and the consequence (clients lose connectivity/DHCP), overridable withbody.force = true(same pattern as the https/ssh lockout at lines 154-169; theconfig_applyhandler at 608-631 already receivesbody). Implementation note: the guard needs current live interfaces for all zones before any mutation — take them from onefirewall-cmd --get-active-zonescall before the zone loop (same pattern asset_zone_interfaces, daemon/handlers/firewall.py:731-732); do not rely on the per-zone--list-allreads 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) pinsmock_backup.assert_not_called(), and the new guard must respect the same no-side-effect-on-conflict invariant. Update the_config_applyandconfig_applydocstrings (both currently document only the lockout guard +force).
- Interfaces step (lines 252-279) — new semantics:
set_zone_interfaces(lines 706-791): when the new selection leaves an interface in no zone, emit a prominentlogger.warning. No block — deliberate UI action.lib/firewall.py,_compute_pending_changes(lines 364-476): remove the blanketif 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 diffset()against live for absent-key zones and emit a spurious entry; compute it only when"interfaces" in zone_cfg. Behavior change: zones withinterfaces: [](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.lib/state.py,_collect_firewall(line 449+): computeuncovered_interfaces(network-config ifaces not in any live zone) on every poll, so it is visible even with zero pending changes. Apply the samelo/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. Adduncovered_interfaces: list[str]toschema.FirewallState(lib/schema.py:87); the TypedDict is shape-checked against the collector return bytests/test_schema_types.py::test_firewall_state(lines 21-54), so the collector must always include the key. Update theFirewallStateblock indocs/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, notrun()— patchlib.network.get_configintest_firewall_state(which mocks onlylib.state.run) so the value is deterministic. This is the detection that would have caught the incident within 30s.- Surface it:
daemon/handlers/status.py(lines 62-105): advisory coverage warnings in the firewall section of/api/status/pending(not counted inneeds_apply).webui/static/pages/zones.js: warning banner fromstate.firewall.data.uncovered_interfaces.docs/api.md(line 1965): document the new advisory field in the/api/status/pendingfirewall section (not counted inneeds_apply/total_changes).docs/api.md: note thatPOST /api/status/apply-allruns the firewall apply withforce=false— a coverageConflictErrorsurfaces in the responseerrorsdict under "Firewall" while the other subsystems proceed (the desired no-silent-apply behavior).
- UI guard: the interfaces
MultiSelectModalin zones.js gets aconfirmhook (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 explicittargetkey or the value normalizes todefault(precedent: the public-masquerade skip at lines 419-424). Defensive — it covers legacy configs still carrying explicit"DEFAULT"._config_applyalready leaves default targets alone (lines 207-219). ExplicitACCEPT/DROP/REJECTremains 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 thandefault. Today the importer emits a faithful snapshot with atargetkey for every zone, and_live_target_to_configmaps livedefault→"DEFAULT"(this is the sole author of the explicit"target": "DEFAULT"entries; no other code path writestargetinto config). Updatetests/test_system_import.py(assertions at lines 538-574). Keep_live_target_to_configitself (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_applynew-zone branch (lines 188-205): run--new-zonefirst (currently missing entirely — the endpoint would create no zone at all), then--set-targetonly when the target normalizes to something other thandefault, then_reload().- Config cleanup (on the appliance): remove the legacy
"target": "DEFAULT"entries from thepublic,vpn-full, andworkzones (making key-absence the one canonical "unmanaged" notation), and add"target": "ACCEPT"to theinternalzone — declares the trusted-LAN intent and makes future apply enforce it and flag any drift. Apply viaPOST /api/firewall/config(full replace) + apply, not a raw JSON edit and notPATCH(which cannot delete keys — see the Decisions note above): GET the current config, drop the threetargetentries, add"target": "ACCEPT"tointernal, POST, then apply. Apply re-stamps_last_applied_hash/_last_applied_configso 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.changesentry: "DHCP range on '' has no firewall zone coverage — inactive until a zone covers it". - Report
dnsmasqinaffected_subsystemsonly 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.jsonvia_save_backup. The permanent view is what is reproducible for manual recovery. Note:_parse_all_zones_outputmust be added to the handler'slib.firewallimport (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
backuppath field is unchanged.load_backuphas 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.pyshutdown path: stop accepting new connections, give in-flight request tasks a bounded grace period (await server.wait_closed()with timeout) beforerunner.cleanup(); suppress the asyncio default exception handler during the teardown window.
Cosmetic. Do last, or defer.
Sequencing & verification
- 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.pypending-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). - Per commit:
.venv/bin/ruff check lib/ webui/ daemon/ tests/.venv/bin/ruff format lib/ webui/ daemon/ tests/.venv/bin/python -m pytest tests/ -vnode tests/test-*.jsfor touched hoover components (zones.js itself has no node test file — verify by loading the page in the running UI).
- Live verification on the appliance after merge:
- Confirm
uncovered_interfacesis 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;internaltarget pinned toACCEPT) and confirm nothing unexpected appears. - Optional drill: create a throwaway zone and move
eth0onto it via the API withforceomitted (expect ConflictError) and added (expect success + warning), then restore. Skip if undesired — mocked tests cover the logic.
- Confirm
- No live-system changes during implementation; DHCP/zone state stays as the operator left it (internal=eth1, public=eth0, leases confirmed 01:29).