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",