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
+24 -24
View File
@@ -742,20 +742,22 @@ class WgToFirewallSync:
class FirewallToDhcpSync:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
that zone's interfaces carry the gateway (interface IP).
Logs warnings for zones with dhcp service but no range.
Keeps DHCP ranges whose interface no longer belongs to any firewall
zone, flagging them as inactive (never deleted). When masquerade is
enabled on a zone, ensures DHCP ranges on that zone's interfaces carry
the gateway (interface IP). Logs warnings for zones with dhcp service
but no range.
"""
@classmethod
def on_firewall_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
that zone's interfaces carry the gateway (interface IP). Logs warnings
for zones with dhcp service but no range.
Keeps DHCP ranges whose interface no longer belongs to any firewall
zone, flagging them as inactive (never deleted). When masquerade is
enabled on a zone, ensures DHCP ranges on that zone's interfaces
carry the gateway (interface IP). Logs warnings for zones with dhcp
service but no range.
Skips processing if event originated as a cascade from ``dnsmasq``.
@@ -763,9 +765,10 @@ class FirewallToDhcpSync:
event: Sync event with ``config_saved`` action from firewall.
Returns:
SyncResult listing dnsmasq as affected subsystem when ranges were
modified, with change descriptions. ``None`` if skipped due to
cascade guard.
SyncResult listing dnsmasq as affected subsystem only when the
gateway auto-fill step mutated config, with change descriptions
(including advisory entries for uncovered ranges). ``None`` if
skipped due to cascade guard.
"""
if event.payload.get("_cascade") == "dnsmasq":
return None
@@ -803,23 +806,20 @@ class FirewallToDhcpSync:
changes: list[str] = []
# Auto-remove stale DHCP ranges (interface no longer in any zone)
# Flag uncovered DHCP ranges (interface no longer in any zone)
# kept in config, not deleted
stale_ifaces = range_ifaces - all_zone_ifaces
if stale_ifaces:
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
remaining = [
r
for r in ranges
if not r.get("interface") or r["interface"] not in stale_ifaces
]
dnsmasq_cfg.setdefault("dhcp", {})["ranges"] = remaining
_save_dnsmasq_cfg(dnsmasq_cfg)
for iface in sorted(stale_ifaces):
logger.info(
"Removed stale DHCP range on '%s' (no firewall zone)",
logger.warning(
"DHCP range on '%s' has no firewall zone coverage — "
"inactive until a zone covers it",
iface,
)
changes.append(f"Removed stale DHCP range on interface '{iface}'")
changes.append(
f"DHCP range on '{iface}' has no firewall zone coverage — "
f"inactive until a zone covers it"
)
# When masquerade is enabled on a zone, ensure DHCP ranges have gateway
changed = False
@@ -863,7 +863,7 @@ class FirewallToDhcpSync:
changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range")
return SyncResult(
affected_subsystems=["dnsmasq"] if changed or stale_ifaces else [],
affected_subsystems=["dnsmasq"] if changed else [],
changes=changes,
)
except Exception: