""" 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 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``.""" _ensure_config_file() return load_json(CONFIG_FILE) 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 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", {}) 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, {}) 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", [])) 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 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", ]