firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync

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
This commit is contained in:
2026-08-28 23:38:21 +00:00
parent 55309cfd86
commit ac52918df5
25 changed files with 1677 additions and 168 deletions
+56
View File
@@ -183,6 +183,62 @@ class TestStatusPending:
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
def test_firewall_uncovered_advisory_not_counted(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
"uncovered_interfaces": ["eth1"],
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == ["eth1"]
assert result["firewall"]["coverage_warnings"]
assert "eth1" in result["firewall"]["coverage_warnings"][0]
# Advisory: must not flip needs_apply or count as a change.
assert not result["firewall"]["needs_apply"]
assert result["firewall"]["change_count"] == 0
assert result["total_changes"] == 0
def test_firewall_no_uncovered_no_warnings(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
"uncovered_interfaces": [],
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == []
assert result["firewall"]["coverage_warnings"] == []
assert result["total_changes"] == 0
def test_firewall_missing_uncovered_key_defaults_empty(self):
with self._patch_store(
{
"firewall": {
"pending": {"needs_apply": False, "pending": []},
},
"dnsmasq": None,
"nginx": None,
"wireguard": None,
"networkd": None,
}
):
result = status.status_pending(None, None)
assert result["firewall"]["uncovered_interfaces"] == []
assert result["firewall"]["coverage_warnings"] == []
def test_firewall_no_pending_key(self):
with self._patch_store(
{