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