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