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
+125 -25
View File
@@ -6,9 +6,11 @@ All privileged commands are handled by daemon/handlers/firewall.py.
"""
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
from lib.common import load_json, save_json
@@ -84,11 +86,21 @@ def _parse_interfaces(output: str) -> list[str]:
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` or a zone block
from ``--list-all-zones`` output.
firewalld emits each rich rule on its own tab-indented continuation
line after an (empty) ``rich rules:`` entry; those lines carry no
colon and are collected into the ``rich-rules`` list.
"""
info: dict[str, Any] = {"name": zone}
last_key = ""
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
if not line:
continue
if ":" not in line:
# Continuation line (rich rules); ignore anything else.
if last_key == "rich-rules":
info.setdefault("rich-rules", []).append(line)
continue
key, _, value = line.partition(":")
key = key.strip()
@@ -98,6 +110,7 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
if key == "rich rules":
key = "rich-rules"
last_key = key
if not value:
if key in ("masquerade", "ics"):
@@ -176,6 +189,80 @@ def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
return zones
# ---------------------------------------------------------------------------
# Service catalog descriptions (firewalld service XML definitions)
# ---------------------------------------------------------------------------
# Built-ins first, /etc second, so user service definitions under
# /etc/firewalld/services override built-ins with the same name.
_SERVICE_XML_DIRS: tuple[Path, ...] = (
Path("/usr/lib/firewalld/services"),
Path("/etc/firewalld/services"),
)
_service_descriptions_cache: dict[str, str] | None = None
def _parse_service_xml(path: Path) -> str:
"""Extract the one-line text from a firewalld service XML definition.
Args:
path: Path to a ``<service>`` XML file.
Returns:
The ``<short>`` text, or ``<description>`` when ``<short>`` is
absent; empty string when neither is present or the file cannot be
read or parsed.
"""
try:
root = ElementTree.parse(path).getroot()
except (OSError, ElementTree.ParseError):
logger.warning("Could not read service definition %s", path, exc_info=True)
return ""
text = root.findtext("short") or root.findtext("description") or ""
return text.strip()
def get_service_descriptions(
dirs: Sequence[Path | str] | None = None,
) -> dict[str, str]:
"""Return a mapping of firewalld service names to one-line descriptions.
Parses the ``*.xml`` service definitions found in *dirs*. When *dirs* is
``None`` the standard system locations are used (see
``_SERVICE_XML_DIRS``) and the result is cached for the process lifetime.
When *dirs* is given the result is computed fresh and nothing is cached.
Unreadable or malformed files are skipped.
Args:
dirs: Directories containing service XML files. ``None`` selects the
default system locations.
Returns:
Dict mapping each service name (file stem) to its description text.
"""
global _service_descriptions_cache
if dirs is None and _service_descriptions_cache is not None:
return dict(_service_descriptions_cache)
search_dirs = [Path(d) for d in dirs] if dirs is not None else _SERVICE_XML_DIRS
descriptions: dict[str, str] = {}
for directory in search_dirs:
try:
entries = sorted(directory.glob("*.xml")) if directory.is_dir() else []
except OSError:
logger.warning("Skipping unreadable service directory %s", directory)
continue
for path in entries:
text = _parse_service_xml(path)
if text:
descriptions[path.stem] = text
if dirs is None:
_service_descriptions_cache = descriptions
return descriptions
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
@@ -282,6 +369,15 @@ def _compute_pending_changes(
Pure function — no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
The interfaces diff is only reported for zones whose config explicitly
carries an ``interfaces`` key; zones with the key absent are hands-off
(apply keeps their live interfaces), so diffing them would advertise
changes that never happen. Likewise the target diff is only reported when
the config carries an explicit target that normalizes to something other
than ``default`` — an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones.
"""
cfg_zones = cfg.get("zones", {})
@@ -290,20 +386,19 @@ def _compute_pending_changes(
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if not zone_cfg.get("interfaces"):
continue
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
if "interfaces" in zone_cfg:
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.get("services", []))
@@ -317,17 +412,21 @@ def _compute_pending_changes(
}
)
cfg_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
# Target is unmanaged when the config omits the key or the value
# normalizes to "default" (firewalld's implicit target, which apply
# never re-sets). Only an explicit ACCEPT/DROP/REJECT is diffed.
if "target" in zone_cfg and _normalize_target(zone_cfg["target"]) != "default":
cfg_target = _normalize_target(zone_cfg["target"])
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
# public zone masquerade is not reconciled by apply (it is driven by
# the nftables propagation step in daemon/handlers/firewall.py), so
@@ -451,6 +550,7 @@ __all__ = [
"config_pending",
"fw_change_summary",
"get_config",
"get_service_descriptions",
"load_backup",
"save_backup",
"save_config",
+6
View File
@@ -94,6 +94,10 @@ class FirewallState(TypedDict):
catch-all zone for interfaces with no explicit assignment.
interfaces: All system interfaces (see FirewallInterface).
available_services: firewalld service catalog ("--get-services").
service_descriptions: Service name to one-line description, parsed
from the firewalld service XML definitions
(``lib.firewall.get_service_descriptions``).
uncovered_interfaces: Network-config interfaces (excluding ``lo``/``wg*``) not in any live zone (advisory coverage warning, always present).
zones: All zones as runtime dicts (see FirewallZone).
rich_rules: Zone name → raw firewalld rich-rule strings.
pending: config_pending() result:
@@ -107,6 +111,8 @@ class FirewallState(TypedDict):
default_zone: str
interfaces: list[FirewallInterface]
available_services: list[str]
service_descriptions: dict[str, str]
uncovered_interfaces: list[str]
zones: dict[str, FirewallZone]
rich_rules: dict[str, list[str]]
pending: dict[str, Any]
+20
View File
@@ -26,10 +26,12 @@ from lib.common import (
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
get_service_descriptions,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import get_config as _network_get_config
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
@@ -538,11 +540,29 @@ def _collect_firewall() -> schema.FirewallState:
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
net_cfg: dict[str, Any] = {}
with contextlib.suppress(Exception):
net_cfg = _network_get_config()
covered: set[str] = set()
for zone_ifaces in active.values():
covered.update(zone_ifaces)
for zone in zones.values():
covered.update(zone.get("interfaces", []))
uncovered_interfaces = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg") and name not in covered
]
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
# Parsed from the firewalld service XML definitions; cached per
# process so the 30s poll does not re-read the files.
"service_descriptions": get_service_descriptions(),
"uncovered_interfaces": uncovered_interfaces,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
+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:
+12 -6
View File
@@ -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")