""" firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance. Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT, and port-forwarding. All mutations are --permanent followed by --reload. A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the Flask UI can inspect or restore previous configurations. """ import logging from contextlib import suppress from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import uuid4 from lib.common import load_json, run, 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 _reload() -> None: """Reload firewalld so permanent changes take effect immediately.""" try: run(["firewall-cmd", "--reload"], sudo=True) logger.info("firewalld reloaded") except RuntimeError as exc: logger.error("firewalld reload failed: %s", exc) raise def _gen_id() -> str: """Generate a short unique identifier (8 hex characters).""" return uuid4().hex[:8] # --------------------------------------------------------------------------- # Read-only queries # --------------------------------------------------------------------------- def get_available_zones() -> list[str]: """Return the list of all built-in (available) firewalld zone names.""" output = run(["firewall-cmd", "--get-zones"], sudo=True) return output.split() def get_active_zones() -> dict[str, list[str]]: """Return a dict mapping active zone names to their assigned interfaces.""" output = run(["firewall-cmd", "--get-active-zones"], sudo=True) 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 current_zone and piece not in current_ifaces: current_ifaces.append(piece) else: current_zone = stripped zones[current_zone] = [] return zones def get_zone_info(zone: str) -> dict[str, Any]: """Return detailed information for *zone*.""" output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) 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() 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 get_services() -> list[str]: """Return the list of available service names known to firewalld.""" output = run(["firewall-cmd", "--get-services"], sudo=True) return output.split() def get_icmp_blocks() -> list[str]: """Return the list of available ICMP block names.""" output = run(["firewall-cmd", "--get-icmptypes"], sudo=True) return output.split() def get_interfaces() -> list[str]: """Return the list of network interfaces visible via iproute2.""" output = run(["ip", "-o", "link", "show"]) 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 get_rich_rules(zone: str) -> list[str]: """Return the rich rules defined for *zone* as a list of raw strings.""" output = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True) output = output.strip() if not output: return [] rules: list[str] = [] current: list[str] = [] for line in output.splitlines(): raw = line.rstrip() if not raw.endswith(";"): current.append(raw) else: current.append(raw) rules.append(" ".join(current)) current = [] if current: rules.append(" ".join(current)) return rules # --------------------------------------------------------------------------- # Zone CRUD # --------------------------------------------------------------------------- def create_zone(zone: str, target: str = "default") -> None: """Create a new permanent zone in firewalld.""" run( [ "firewall-cmd", f"--zone={zone}", f"--set-target={target}", "--permanent", ], sudo=True, ) _reload() logger.info("Firewall zone '%s' created (target=%s)", zone, target) def delete_zone(zone: str) -> None: """Delete an existing zone.""" run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) _reload() logger.info("Firewall zone '%s' deleted", zone) # --------------------------------------------------------------------------- # Interface assignment # --------------------------------------------------------------------------- def set_zone_interfaces(zone: str, interfaces: list[str]) -> None: """Assign *interfaces* to *zone*, replacing any existing assignments.""" try: current = get_zone_info(zone).get("interfaces", []) except Exception: current = [] for iface in current: run( [ "firewall-cmd", f"--zone={zone}", "--remove-interface=" + iface, "--permanent", ], sudo=True, check=False, ) for iface in interfaces: run( [ "firewall-cmd", f"--zone={zone}", "--add-interface=" + iface, "--permanent", ], sudo=True, ) _reload() logger.info("Zone '%s' interfaces set to %s", zone, interfaces) def add_zone_interface(zone: str, iface: str) -> None: """Add a single interface to *zone*.""" run( [ "firewall-cmd", f"--zone={zone}", "--add-interface=" + iface, "--permanent", ], sudo=True, ) _reload() logger.info("Interface '%s' added to zone '%s'", iface, zone) def remove_zone_interface(zone: str, iface: str) -> None: """Remove a single interface from *zone*.""" run( [ "firewall-cmd", f"--zone={zone}", "--remove-interface=" + iface, "--permanent", ], sudo=True, ) _reload() logger.info("Interface '%s' removed from zone '%s'", iface, zone) # --------------------------------------------------------------------------- # Service management # --------------------------------------------------------------------------- def set_zone_services(zone: str, services: list[str]) -> None: """Set services for *zone*, replacing any previously allowed services.""" current = get_zone_info(zone).get("services", []) for svc in current: run( [ "firewall-cmd", f"--zone={zone}", f"--remove-service={svc}", "--permanent", ], sudo=True, check=False, ) for svc in services: run( [ "firewall-cmd", f"--zone={zone}", f"--add-service={svc}", "--permanent", ], sudo=True, ) _reload() logger.info("Zone '%s' services set to %s", zone, services) def add_zone_service(zone: str, service: str) -> None: """Add a single service to *zone*.""" run( [ "firewall-cmd", f"--zone={zone}", f"--add-service={service}", "--permanent", ], sudo=True, ) _reload() logger.info("Service '%s' added to zone '%s'", service, zone) def remove_zone_service(zone: str, service: str) -> None: """Remove a single service from *zone*.""" run( [ "firewall-cmd", f"--zone={zone}", f"--remove-service={service}", "--permanent", ], sudo=True, ) _reload() logger.info("Service '%s' removed from zone '%s'", service, zone) # --------------------------------------------------------------------------- # Rich rules # --------------------------------------------------------------------------- def add_rich_rule(zone: str, rule: str) -> dict[str, Any]: """Add a rich rule to *zone* and persist to declarative config.""" run( [ "firewall-cmd", f"--zone={zone}", "--add-rich-rule=" + rule, "--permanent", ], sudo=True, ) _reload() _persist_rich_rule(zone, rule) rule_entry = _get_rich_rule_entry(zone, rule) logger.info("Rich rule added to zone '%s': %s", zone, rule[:80]) return rule_entry def remove_rich_rule(zone: str, rule: str) -> None: """Remove a rich rule from *zone*.""" run( [ "firewall-cmd", f"--zone={zone}", "--remove-rich-rule=" + rule, "--permanent", ], sudo=True, ) _reload() _unpersist_rich_rule(zone, rule) logger.info("Rich rule removed from zone '%s': %s", zone, rule[:80]) def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]: """Add a rich rule to the declarative config with a generated id.""" cfg = get_config() cfg.setdefault("zones", {}) cfg["zones"].setdefault(zone, {}) cfg["zones"][zone].setdefault("rich_rules", []) existing_rules = cfg["zones"][zone]["rich_rules"] rule_id = _gen_id() entry = {"id": rule_id, "rule": rule} existing_rules.append(entry) save_config(cfg) return entry def _unpersist_rich_rule(zone: str, rule: str) -> None: """Remove a rich rule from the declarative config by rule string.""" cfg = get_config() zone_cfg = cfg.get("zones", {}).get(zone, {}) rules = zone_cfg.get("rich_rules", []) zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule] save_config(cfg) def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]: """Look up a rich rule entry in the declarative config.""" cfg = get_config() for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []): if r.get("rule") == rule: return r return {"rule": rule} def remove_rich_rule_by_id(zone: str, rule_id: str) -> None: """Remove a rich rule from *zone* by its config id.""" cfg = get_config() zone_cfg = cfg.get("zones", {}).get(zone, {}) entry = None for r in zone_cfg.get("rich_rules", []): if r.get("id") == rule_id: entry = r break if entry is None: raise ValueError(f"Rich rule '{rule_id}' not found in zone '{zone}'") rule = entry["rule"] remove_rich_rule(zone, rule) # --------------------------------------------------------------------------- # Masquerade (NAT) # --------------------------------------------------------------------------- def set_masquerade(zone: str, enable: bool) -> None: """Enable or disable masquerade (source-NAT) on *zone*.""" action = "--add-masquerade" if enable else "--remove-masquerade" run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) _reload() logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone) # --------------------------------------------------------------------------- # Port forwarding # --------------------------------------------------------------------------- def add_forward_port( zone: str, port: int, protocol: str, toaddr: str | None = None, toport: int | None = None, ) -> dict[str, Any]: """Add a port forwarding rule to *zone* and persist to declarative config.""" fwd = f"port={port}/proto={protocol}" if toaddr and toport: fwd += f"/toaddr={toaddr}/toport={toport}" elif toport: fwd += f"/toport={toport}" else: fwd += f"/toaddr={toaddr}" if toaddr else "" run( [ "firewall-cmd", f"--zone={zone}", f"--add-forward-port={fwd}", "--permanent", ], sudo=True, ) _reload() _persist_forward_port(zone, port, protocol, toaddr, toport) fp_entry = _get_forward_port_entry(zone, port, protocol) logger.info("Port forward added to zone '%s': %s", zone, fwd) return fp_entry def remove_forward_port( zone: str, port: int, protocol: str, toaddr: str | None = None, toport: int | None = None, ) -> None: """Remove a previously added port-forwarding rule from *zone*.""" fwd = f"port={port}/proto={protocol}" if toaddr and toport: fwd += f"/toaddr={toaddr}/toport={toport}" elif toport: fwd += f"/toport={toport}" else: fwd += f"/toaddr={toaddr}" if toaddr else "" run( [ "firewall-cmd", f"--zone={zone}", f"--remove-forward-port={fwd}", "--permanent", ], sudo=True, ) _reload() _unpersist_forward_port(zone, port, protocol) logger.info("Port forward removed from zone '%s': %s", zone, fwd) def _persist_forward_port( zone: str, port: int, protocol: str, toaddr: str | None = None, toport: int | None = None, ) -> dict[str, Any]: """Add a forward port to the declarative config with a generated id.""" cfg = get_config() cfg.setdefault("zones", {}) cfg["zones"].setdefault(zone, {}) cfg["zones"][zone].setdefault("forward_ports", []) fp_id = _gen_id() entry: dict[str, Any] = { "id": fp_id, "port": port, "proto": protocol, } if toaddr: entry["toaddr"] = toaddr if toport: entry["toport"] = toport cfg["zones"][zone]["forward_ports"].append(entry) save_config(cfg) return entry def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None: """Remove a forward port from the declarative config by port+proto.""" cfg = get_config() fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", []) cfg.setdefault("zones", {}).setdefault(zone, {}) cfg["zones"][zone]["forward_ports"] = [ fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol) ] save_config(cfg) def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]: """Look up a forward port entry in the declarative config.""" cfg = get_config() for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []): if fp.get("port") == port and fp.get("proto") == protocol: return fp entry: dict[str, Any] = {"port": port, "proto": protocol} return entry def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None: """Remove a forward port from *zone* by port+proto (id used by API layer).""" cfg = get_config() zone_cfg = cfg.get("zones", {}).get(zone, {}) entry = None for fp in zone_cfg.get("forward_ports", []): if fp.get("port") == port and fp.get("proto") == protocol: entry = fp break if entry is None: raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'") remove_forward_port( zone, port, protocol, toaddr=entry.get("toaddr"), toport=entry.get("toport"), ) # --------------------------------------------------------------------------- # 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 get_state() -> dict[str, Any]: """Return the complete current state of firewalld as a Python dict.""" zones: dict[str, dict[str, Any]] = {} for name in get_available_zones(): try: zones[name] = get_zone_info(name) except Exception: continue return { "active_zones": get_active_zones(), "interfaces": get_interfaces(), "available_services": get_services(), "zones": zones, "rich_rules": {name: get_rich_rules(name) for name in zones}, "timestamp": _now_iso(), } def _now_iso() -> str: """Return the current UTC time as an ISO-8601 string.""" return datetime.now(UTC).isoformat() def save_backup() -> str: """Capture the full state and write it to RULES_FILE on disk.""" state = get_state() save_json(RULES_FILE, state) logger.info("Firewall state backup saved to %s", RULES_FILE) return RULES_FILE def load_backup() -> dict[str, Any]: """Read the JSON backup file and return the state dict.""" return load_json(RULES_FILE) def restore_backup(state: dict[str, Any]) -> None: """Apply the zone configuration described in *state*.""" zones_cfg = state.get("zones", {}) for zone_name, zinfo in zones_cfg.items(): if zone_name not in get_available_zones(): target = zinfo.get("target", "default") create_zone(zone_name, target) services = zinfo.get("services", []) set_zone_services(zone_name, services) interfaces = zinfo.get("interfaces", []) set_zone_interfaces(zone_name, interfaces) if zinfo.get("masquerade"): set_masquerade(zone_name, True) for fp in zinfo.get("forward-ports", []): if isinstance(fp, str): fp_str = fp else: parts = [f"port={fp['port']}", f"proto={fp['proto']}"] if "toaddr" in fp: parts.append(f"toaddr={fp['toaddr']}") if "toport" in fp: parts.append(f"toport={fp['toport']}") fp_str = "/".join(parts) run( [ "firewall-cmd", f"--zone={zone_name}", f"--add-forward-port={fp_str}", "--permanent", ], sudo=True, check=False, ) for rule in zinfo.get("rich-rules", []): run( [ "firewall-cmd", f"--zone={zone_name}", f"--add-rich-rule={rule}", "--permanent", ], sudo=True, check=False, ) _reload() logger.info("Firewall backup restored, %d zones processed", len(zones_cfg)) # --------------------------------------------------------------------------- # 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 config_pending() -> dict[str, Any]: """Compare declarative config against live firewalld state, return diff.""" cfg = get_config() live_state = get_state() cfg_zones = cfg.get("zones", {}) live_zones = live_state.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_apply() -> dict[str, Any]: """Apply the declarative config to live firewalld.""" cfg = get_config() cfg_zones = cfg.get("zones", {}) save_backup() available = get_available_zones() applied: list[str] = [] for zone_name, zone_cfg in cfg_zones.items(): need_create = zone_name not in available if need_create: target = _normalize_target(zone_cfg.get("target", "DEFAULT")) create_zone(zone_name, target) else: desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT")) if desired_target != "default": with suppress(RuntimeError): run( [ "firewall-cmd", f"--zone={zone_name}", f"--set-target={desired_target}", "--permanent", ], sudo=True, check=False, ) set_zone_services(zone_name, zone_cfg.get("services", [])) set_zone_interfaces(zone_name, zone_cfg.get("interfaces", [])) mq = zone_cfg.get("masquerade", False) if mq is not None: set_masquerade(zone_name, mq) for rule_entry in zone_cfg.get("rich_rules", []): rule_str = ( rule_entry.get("rule", "") if isinstance(rule_entry, dict) else str(rule_entry) ) if rule_str: run( [ "firewall-cmd", f"--zone={zone_name}", f"--add-rich-rule={rule_str}", "--permanent", ], sudo=True, check=False, ) for fp_entry in zone_cfg.get("forward_ports", []): if isinstance(fp_entry, str): fp_str = fp_entry else: parts = [f"port={fp_entry['port']}", f"proto={fp_entry['proto']}"] if "toaddr" in fp_entry: parts.append(f"toaddr={fp_entry['toaddr']}") if "toport" in fp_entry: parts.append(f"toport={fp_entry['toport']}") fp_str = "/".join(parts) run( [ "firewall-cmd", f"--zone={zone_name}", f"--add-forward-port={fp_str}", "--permanent", ], sudo=True, check=False, ) applied.append(zone_name) _reload() backup_path = save_backup() logger.info("Firewall config applied to %d zones", len(applied)) return { "applied_zones": applied, "backup": backup_path, } __all__ = [ "CONFIG_DIR", "CONFIG_FILE", "DATA_DIR", "DEFAULT_CONFIG", "RULES_FILE", "_reload", "add_forward_port", "add_rich_rule", "add_zone_interface", "add_zone_service", "config_apply", "config_pending", "create_zone", "delete_zone", "get_active_zones", "get_available_zones", "get_config", "get_icmp_blocks", "get_interfaces", "get_rich_rules", "get_services", "get_state", "get_zone_info", "load_backup", "remove_forward_port", "remove_forward_port_by_id", "remove_rich_rule", "remove_rich_rule_by_id", "remove_zone_interface", "remove_zone_service", "restore_backup", "save_backup", "save_config", "set_masquerade", "set_zone_interfaces", "set_zone_services", ]