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
+135 -58
View File
@@ -34,10 +34,13 @@ from daemon.iface import (
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
_normalize_target,
_now_iso,
_parse_active_zones,
_parse_all_zones_output,
_parse_zone_output,
fw_change_summary,
)
@@ -143,8 +146,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
then adding desired values. Reloads firewalld at the end.
With *force* False (default), a ``ConflictError`` is raised before any
mutation if the config would strip both https and ssh from the default
zone; pass ``force=True`` to override.
mutation in two cases; pass ``force=True`` to override either:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so
their current live interfaces count as coverage, as do the live
interfaces of zones that are live but absent from the config.
"""
from lib.firewall import get_config as _get_lib_config
@@ -168,15 +178,52 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
f'to the zone\'s services, or pass {{"force": true}}.'
)
full_state: dict[str, Any] = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
_save_backup(full_state)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
# the declarative config, captured before any mutation. The permanent
# view is what is reproducible for manual recovery.
backup_path = _save_backup(
{
"timestamp": _now_iso(),
"default_zone": _default_zone(),
"zones": _parse_all_zones_output(
run(["firewall-cmd", "--list-all-zones", "--permanent"], sudo=True)
),
"config": cfg,
}
)
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
applied: list[str] = []
@@ -250,33 +297,38 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
@@ -382,15 +434,6 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
_save_config(cfg)
_reload()
full_state = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
backup_path = _save_backup(full_state)
# Record the applied config snapshot + hash so pending-changes detection
# and cancel/revert work like the hash-based subsystems.
applied_cfg = _get_config()
@@ -612,11 +655,16 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Args:
_request: The incoming HTTP request (unused).
_body: Optional JSON body; ``{"force": true}`` overrides the
management-lockout guard for the default zone.
management-lockout guard and the interface-coverage guard.
Returns:
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
and ``synced`` (affected subsystems).
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
@@ -631,7 +679,11 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_FIREWALL_ZONES_CREATE)
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Create new zone via firewall-cmd, emit sync event, refresh state.
"""Create a new firewall zone, emit sync event, refresh state.
Runs ``--new-zone`` first (required before ``--set-target``), then sets
the target only when it normalizes to something other than ``default``
(the implicit firewalld target is never re-set), then reloads.
Args:
_request: The incoming HTTP request (unused).
@@ -652,15 +704,21 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
if zone_name in available:
raise ValueError(f"Zone '{zone_name}' already exists")
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
sudo=True,
)
# Create the zone first; --set-target requires the zone to exist.
run(["firewall-cmd", f"--new-zone={zone_name}", "--permanent"], sudo=True)
# "default" is firewalld's implicit target and cannot be meaningfully
# re-set, so only explicit ACCEPT/DROP/REJECT targets are applied.
normalized_target = _normalize_target(target)
if normalized_target != "default":
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={normalized_target}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit(
@@ -707,6 +765,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Replace zone interfaces, reassigning interfaces from old zones.
When the new selection leaves an interface in no zone at all, a
prominent warning is logged (clients on that segment lose connectivity
and DHCP); the operation is not blocked since it is a deliberate UI
action.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``interfaces`` list.
@@ -761,6 +824,20 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
sudo=True,
)
# Flag interfaces that ended up in no zone at all — clients on those
# segments lose connectivity (including DHCP).
for iface in set(active.get(zone, [])) - set(interfaces):
if not any(
iface in az_ifaces for az, az_ifaces in active.items() if az != zone
):
logger.warning(
"Interface '%s' is now in NO firewall zone: clients on that "
"segment will lose connectivity and DHCP (zone '%s' no longer "
"covers it).",
iface,
zone,
)
_reload()
# Update config