""" 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 datetime import UTC, datetime from pathlib import Path from typing import Any 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": {}} # --------------------------------------------------------------------------- # 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. """ info: dict[str, Any] = {"name": zone} for line in output.splitlines(): line = line.strip() if not line or ":" not in 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" 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 # --------------------------------------------------------------------------- # 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). """ 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 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), } ) 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), } ) 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, } ) 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: 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", "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", "load_backup", "save_backup", "save_config", ]