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:
+12
-6
@@ -918,18 +918,24 @@ def import_firewall() -> bool:
|
||||
logger.warning("Failed to parse firewall zones", exc_info=True)
|
||||
return False
|
||||
|
||||
zone_configs = {
|
||||
zone_name: {
|
||||
"target": _live_target_to_config(parsed["target"]),
|
||||
zone_configs: dict[str, dict[str, Any]] = {}
|
||||
for zone_name, parsed in zones.items():
|
||||
if not parsed["interfaces"]:
|
||||
continue
|
||||
zone_cfg: dict[str, Any] = {
|
||||
"interfaces": parsed["interfaces"],
|
||||
"services": parsed["services"],
|
||||
"masquerade": parsed["masquerade"],
|
||||
"rich_rules": [{"rule": r} for r in parsed["rich-rules"]],
|
||||
"forward_ports": parsed["forward-ports"],
|
||||
}
|
||||
for zone_name, parsed in zones.items()
|
||||
if parsed["interfaces"]
|
||||
}
|
||||
# Omit the target key when the live target normalizes to firewalld's
|
||||
# implicit "default" so key-absence is the one canonical "unmanaged"
|
||||
# notation; keep explicit ACCEPT/DROP/REJECT targets.
|
||||
target = _live_target_to_config(parsed["target"])
|
||||
if target != "DEFAULT":
|
||||
zone_cfg["target"] = target
|
||||
zone_configs[zone_name] = zone_cfg
|
||||
|
||||
if not zone_configs:
|
||||
logger.debug("Skipping firewall: no zones with interfaces")
|
||||
|
||||
Reference in New Issue
Block a user