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:
+125
-25
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user