""" firewall.py - firewalld parsing helpers & declarative config for Vacuum Wall. Pure logic only — no subprocess or sudo calls. All privileged commands are handled by daemon/handlers/firewall.py. """ import logging from collections.abc import Sequence from copy import deepcopy 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 logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent DATA_DIR: Path = PROJECT_DIR / "data" / "firewall" RULES_FILE: Path = DATA_DIR / "rules.json" CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall" CONFIG_FILE: Path = CONFIG_DIR / "config.json" DEFAULT_CONFIG: dict[str, Any] = {"zones": {}} # Zones firewalld ships by default. They are always present live and are # never meaningful to flag as "unmanaged (not in config)". FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset( { "block", "dmz", "drop", "external", "home", "host", "internal", "public", "trusted", } ) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _parse_active_zones(output: str) -> dict[str, list[str]]: """Parse ``firewall-cmd --get-active-zones`` output.""" zones: dict[str, list[str]] = {} current_zone: str | None = None for raw_line in output.splitlines(): stripped = raw_line.strip() if not stripped: continue if raw_line.startswith(" "): current_ifaces = ( zones[current_zone] if current_zone else zones.get(list(zones.keys())[-1], []) ) for piece in stripped.split(): if piece.endswith(":"): continue if current_zone and piece not in current_ifaces: current_ifaces.append(piece) else: current_zone = stripped.removesuffix(" (default)") zones[current_zone] = [] return zones def _parse_interfaces(output: str) -> list[str]: """Parse ``ip -o link show`` output.""" ifaces: list[str] = [] for line in output.splitlines(): if line: parts = line.split() if len(parts) >= 2: name = parts[1].rstrip(":") ifaces.append(name) return ifaces 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: 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() value = value.strip() # --list-all-zones uses "rich rules" (space) while # --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"): info[key] = False else: info[key] = [] else: if key in ( "interfaces", "sources", "services", "ports", "protocols", "icmp-blocks", "module", ): info[key] = value.split() elif key == "forward-ports": info[key] = _parse_forward_ports(value) elif key in ("masquerade", "ics"): info[key] = value.lower() == "yes" elif key == "rich-rules": info[key] = [value] if value else [] else: info[key] = value info.setdefault("rich-rules", []) info.setdefault("interfaces", []) info.setdefault("sources", []) info.setdefault("services", []) info.setdefault("ports", []) info.setdefault("protocols", []) info.setdefault("forward-ports", []) info.setdefault("masquerade", False) info.setdefault("ics", False) info.setdefault("icmp-blocks", []) info.setdefault("module", []) info.setdefault("target", "default") return info def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]: """Parse the combined ``firewall-cmd --list-all-zones`` output. Returns a dict mapping each zone name to its parsed info dict (same structure as ``_parse_zone_output``). """ zones: dict[str, dict[str, Any]] = {} current_name: str | None = None current_lines: list[str] = [] for raw_line in output.splitlines(): if not raw_line.strip(): continue # Non-indented line starts a new zone block if raw_line[0].isspace(): if current_name is not None: current_lines.append(raw_line.strip()) else: # Finalize previous zone if current_name is not None and current_lines: zones[current_name] = _parse_zone_output( current_name, "\n".join(current_lines) ) # Extract zone name (discard trailing parenthetical metadata) name = raw_line.strip().split()[0] if "(" in name: name = name[: name.index("(")] current_name = name current_lines = [] # Finalize last zone if current_name is not None and current_lines: zones[current_name] = _parse_zone_output(current_name, "\n".join(current_lines)) 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 ```` XML file. Returns: The ```` text, or ```` when ```` 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 # --------------------------------------------------------------------------- def _parse_forward_port(raw: str) -> dict[str, Any]: """Parse a single forward-port specifier into a structured dict.""" result: dict[str, Any] = {} for piece in raw.split("/"): if "=" not in piece: continue key, _, val = piece.partition("=") if key == "port": result["port"] = int(val) elif key == "proto": result["proto"] = val elif key == "toaddr": result["toaddr"] = val elif key == "toport": result["toport"] = int(val) return result def _parse_forward_ports(value: str) -> list[dict[str, Any]]: """Parse the 'forward-ports' line into a list of structured dicts.""" if not value: return [] return [_parse_forward_port(raw) for raw in value.split()] # --------------------------------------------------------------------------- # State snapshot / backup helpers # --------------------------------------------------------------------------- def _now_iso() -> str: """Return the current UTC time as an ISO-8601 string.""" return datetime.now(UTC).isoformat() def save_backup(state: dict[str, Any]) -> str: """Write *state* to RULES_FILE on disk.""" save_json(RULES_FILE, state) logger.info("Firewall state backup saved to %s", RULES_FILE) return str(RULES_FILE) def load_backup() -> dict[str, Any]: """Read the JSON backup file and return the state dict.""" return load_json(RULES_FILE) # --------------------------------------------------------------------------- # Declarative config management (config/firewall/config.json) # --------------------------------------------------------------------------- def _ensure_config_file() -> None: """Create config directory and file if they do not exist.""" if not CONFIG_FILE.exists(): save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2) def get_config() -> dict[str, Any]: """Return the declarative config from ``config/firewall/config.json``. Pure read — never writes. Returns the in-memory default when the file is missing; the file is materialized on the first ``save_config`` (or by the system-config import on first start). """ raw = load_json(CONFIG_FILE) if not raw: return deepcopy(DEFAULT_CONFIG) return raw def save_config(cfg: dict[str, Any]) -> None: """Write *cfg* to ``config/firewall/config.json`` (atomic replace).""" _ensure_config_file() save_json(CONFIG_FILE, cfg, indent=2) logger.info("Firewall declarative config saved") def _normalize_target(target: str) -> str: """Map between config JSON target names and firewalld target values.""" if target == "ACCEPT": return "ACCEPT" if target == "DROP": return "DROP" if target == "REJECT": return "REJECT" return "default" def _live_target_to_config(target: str) -> str: """Map firewalld target value back to config JSON canonical form.""" if target == "ACCEPT": return "ACCEPT" if target == "DROP": return "DROP" if target == "REJECT": return "REJECT" return "DEFAULT" def _compute_pending_changes( cfg: dict[str, Any], live_zones: dict[str, dict[str, Any]], ) -> dict[str, Any]: """Compare declarative config against live zone state, return diff. Pure function — no subprocess calls. Caller is responsible for providing live state (typically from the daemon). The config is the source of truth for zone interfaces: an absent ``interfaces`` key counts as an empty list, so every config zone is diffed on interfaces. 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", {}) changes: list[dict[str, Any]] = [] unknown_live: dict[str, Any] = {} for zone_name, zone_cfg in cfg_zones.items(): live_zone = live_zones.get(zone_name, {}) # The config is the source of truth for zone interfaces: an absent # key counts as an empty list, so every config zone is diffed. 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", [])) if cfg_services != live_services: changes.append( { "zone": zone_name, "type": "services", "config": sorted(cfg_services), "live": sorted(live_services), } ) # 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 # reporting it as pending here would advertise a change that never # happens. Skip it to keep the diff consistent with apply. if zone_name != "public": cfg_mq = zone_cfg.get("masquerade", False) live_mq = live_zone.get("masquerade", False) if cfg_mq != live_mq: changes.append( { "zone": zone_name, "type": "masquerade", "config": cfg_mq, "live": live_mq, } ) cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])} live_rules = set(live_zone.get("rich-rules", [])) if cfg_rules != live_rules: changes.append( { "zone": zone_name, "type": "rich_rules", "config_count": len(cfg_rules), "live_count": len(live_rules), } ) cfg_fps = { (fp.get("port"), fp.get("proto")) for fp in zone_cfg.get("forward_ports", []) } live_fps = { (fp.get("port"), fp.get("proto")) for fp in live_zone.get("forward-ports", []) } if cfg_fps != live_fps: changes.append( { "zone": zone_name, "type": "forward_ports", "config_count": len(cfg_fps), "live_count": len(live_fps), } ) for zone_name in live_zones: if zone_name not in cfg_zones and zone_name not in FIREWALLD_BUILTIN_ZONES: unknown_live[zone_name] = { "interfaces": live_zones[zone_name].get("interfaces", []), } return { "pending": changes, "needs_apply": len(changes) > 0, "unmanaged_zones": unknown_live, } def validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]: """Return network-managed interfaces with no firewall zone coverage. Pure — compares the declarative firewall config against the network config; no live state. A managed interface is covered when it appears in some zone's ``interfaces`` list (an absent key counts as empty), or is explicitly declared in the top-level ``unmanaged`` list. ``lo`` and ``wg*`` interfaces are never guarded (VPN zones are managed by the WireGuard sync; loopback is normally zoneless). Args: fw_cfg: Firewall declarative config (``zones`` plus optional top-level ``unmanaged`` list). net_cfg: Network config (``interfaces`` mapping). Returns: Sorted list of uncovered interface names; empty when the config is valid. """ managed = [ name for name in net_cfg.get("interfaces", {}) if name != "lo" and not name.startswith("wg") ] if not managed: return [] covered: set[str] = set() for zone_cfg in fw_cfg.get("zones", {}).values(): if isinstance(zone_cfg, dict): covered.update( i for i in zone_cfg.get("interfaces", []) if isinstance(i, str) ) unmanaged_raw = fw_cfg.get("unmanaged", []) unmanaged = ( {i for i in unmanaged_raw if isinstance(i, str)} if isinstance(unmanaged_raw, list) else set() ) return sorted( name for name in managed if name not in covered and name not in unmanaged ) def config_pending(state: dict[str, Any]) -> dict[str, Any]: """Compare declarative config against firewalld live state, return diff. *state* is required — the daemon always passes live state via `daemon.handlers.firewall.get_state()`. """ cfg = get_config() live_zones = state.get("zones", {}) return _compute_pending_changes(cfg, live_zones) def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str: """Build a human-readable summary string for a firewall change.""" if ctype == "interfaces": config_if = change.get("config", []) live_if = change.get("live", []) return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})" if ctype == "services": config_sv = change.get("config", []) live_sv = change.get("live", []) return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})" if ctype == "rich_rules": cfg_count = change.get("config_count", 0) live_count = change.get("live_count", 0) return ( f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})" ) if ctype == "forward_ports": cfg_count = change.get("config_count", 0) live_count = change.get("live_count", 0) return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})" if ctype == "masquerade": cfg_val = change.get("config", False) live_val = change.get("live", False) return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})" if ctype == "target": cfg_val = change.get("config", "default") live_val = change.get("live", "default") return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})" return f"Zone {zone}: {ctype} changed" __all__ = [ "CONFIG_DIR", "CONFIG_FILE", "DATA_DIR", "DEFAULT_CONFIG", "FIREWALLD_BUILTIN_ZONES", "RULES_FILE", "_compute_pending_changes", "_ensure_config_file", "_live_target_to_config", "_normalize_target", "_now_iso", "_parse_active_zones", "_parse_all_zones_output", "_parse_forward_ports", "_parse_interfaces", "_parse_zone_output", "config_pending", "fw_change_summary", "get_config", "get_service_descriptions", "load_backup", "save_backup", "save_config", "validate_coverage", ]