"""Firewall daemon handler. Executes firewall-cmd and ip commands with sudo, returns structured results. Parsing helpers are imported from lib.firewall. """ import logging from contextlib import suppress from datetime import UTC, datetime from pathlib import Path from typing import Any from daemon.server import NotFoundError, registry from lib.common import load_json, run, save_json from lib.firewall import ( _normalize_target, _parse_active_zones, _parse_zone_output, ) from lib.firewall import ( config_pending as _config_pending, ) from lib.firewall import ( get_config as _get_lib_config, ) from lib.firewall import ( save_backup as _save_backup, ) logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent.parent DATA_DIR = PROJECT_DIR / "data" / "firewall" RULES_FILE = DATA_DIR / "rules.json" CONFIG_DIR = PROJECT_DIR / "config" / "firewall" CONFIG_FILE = CONFIG_DIR / "config.json" DEFAULT_CONFIG = {"zones": {}} # --------------------------------------------------------------------------- # Config helpers # --------------------------------------------------------------------------- def _ensure_config_file() -> None: if not CONFIG_FILE.exists(): CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2) def _get_config() -> dict[str, Any]: _ensure_config_file() return load_json(CONFIG_FILE) def _save_config(cfg: dict[str, Any]) -> None: _ensure_config_file() save_json(CONFIG_FILE, cfg, indent=2) def _reload() -> None: run(["firewall-cmd", "--reload"], sudo=True) def _now_iso() -> str: return datetime.now(UTC).isoformat() def _fp_to_str(fp: dict[str, Any]) -> str: 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']}") return "/".join(parts) def _get_forward_ports(zone_name: str) -> list[str]: with suppress(Exception): fps = _parse_zone_output( zone_name, run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), ).get("forward-ports", []) return [_fp_to_str(fp) for fp in fps if isinstance(fp, dict)] return [] def _get_state() -> dict[str, Any]: """Return the complete current state of firewalld.""" zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split() active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) active = _parse_active_zones(active_raw) services = run(["firewall-cmd", "--get-services"], sudo=True).split() or [] link_out = run(["ip", "-o", "link", "show"], sudo=True) addr_out = run(["ip", "-o", "addr", "show"], sudo=True) iface_map: dict[str, dict[str, Any]] = {} for line in link_out.splitlines(): if not line: continue parts = line.split() if len(parts) < 2: continue raw_name = parts[1].rstrip(":") state = "UNKNOWN" mtu = None mac = None for i, p in enumerate(parts): if p == "state" and i + 1 < len(parts): state = parts[i + 1] if p == "mtu" and i + 1 < len(parts): mtu = int(parts[i + 1]) if p.startswith("link/ether") and i + 1 < len(parts): mac = parts[i + 1] iface_map[raw_name] = { "name": raw_name, "display_name": raw_name.partition("@")[0], "mac": mac, "state": state, "mtu": mtu, "ips": [], "ipv6": [], "zone": None, } for line in addr_out.splitlines(): if not line: continue parts = line.split() if len(parts) < 4: continue addr_name = parts[1] addr_key = "ipv6" if parts[2] == "inet6" else "ips" for entry in iface_map.values(): if entry["display_name"] == addr_name: entry[addr_key].append(parts[3]) break for zone_name, ifaces in active.items(): for raw_if in ifaces: clean = raw_if.partition("@")[0] for entry in iface_map.values(): if entry["display_name"] == clean or entry["name"] == raw_if: entry["zone"] = zone_name break ifaces = list(iface_map.values()) zones: dict[str, dict[str, Any]] = {} for zn in zone_names: try: zones[zn] = _parse_zone_output( zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True) ) except Exception: continue return { "active_zones": active, "interfaces": ifaces, "available_services": services, "zones": zones, "rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()}, "timestamp": _now_iso(), } def _config_apply() -> dict[str, Any]: """Apply the declarative config to live firewalld.""" cfg = _get_lib_config() cfg_zones = cfg.get("zones", {}) _save_backup(_get_state()) available = run(["firewall-cmd", "--get-zones"], sudo=True).split() 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")) run( [ "firewall-cmd", f"--zone={zone_name}", f"--set-target={target}", "--permanent", ], sudo=True, ) _reload() 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, ) current_svcs: list[str] = [] with suppress(Exception): current_svcs = _parse_zone_output( zone_name, run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), ).get("services", []) for svc in current_svcs: run( [ "firewall-cmd", f"--zone={zone_name}", f"--remove-service={svc}", "--permanent", ], sudo=True, check=False, ) for svc in zone_cfg.get("services", []): run( [ "firewall-cmd", f"--zone={zone_name}", f"--add-service={svc}", "--permanent", ], sudo=True, ) current_ifaces: list[str] = [] with suppress(Exception): current_ifaces = _parse_zone_output( zone_name, run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), ).get("interfaces", []) for iface in current_ifaces: run( [ "firewall-cmd", f"--zone={zone_name}", "--remove-interface=" + iface, "--permanent", ], sudo=True, check=False, ) for iface in zone_cfg.get("interfaces", []): run( [ "firewall-cmd", f"--zone={zone_name}", "--add-interface=" + iface, "--permanent", ], sudo=True, ) mq = zone_cfg.get("masquerade", False) if mq is not None: action = "--add-masquerade" if mq else "--remove-masquerade" run( ["firewall-cmd", f"--zone={zone_name}", action, "--permanent"], sudo=True, ) 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, ) current_fps = _get_forward_ports(zone_name) for fp_str in current_fps: run( [ "firewall-cmd", f"--zone={zone_name}", f"--remove-forward-port={fp_str}", "--permanent", ], sudo=True, check=False, ) for fp_entry in zone_cfg.get("forward_ports", []): fp_str = fp_entry if isinstance(fp_entry, str) else _fp_to_str(fp_entry) 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(_get_state()) logger.info("Firewall config applied to %d zones", len(applied)) return { "applied_zones": applied, "backup": backup_path, } # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- _READ_TAGS = {"firewall", "interfaces", "zones"} @registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS) def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]: link_out = run(["ip", "-o", "link", "show"], sudo=True) addr_out = run(["ip", "-o", "addr", "show"], sudo=True) zones_out = run(["firewall-cmd", "--get-active-zones"], sudo=True) iface_map: dict[str, dict[str, Any]] = {} for line in link_out.splitlines(): if not line: continue parts = line.split() if len(parts) < 2: continue raw_name = parts[1].rstrip(":") state = "UNKNOWN" mtu = None mac = None for i, p in enumerate(parts): if p == "state" and i + 1 < len(parts): state = parts[i + 1] if p == "mtu" and i + 1 < len(parts): mtu = int(parts[i + 1]) if p.startswith("link/ether") and i + 1 < len(parts): mac = parts[i + 1] iface_map[raw_name] = { "name": raw_name, "display_name": raw_name.partition("@")[0], "mac": mac, "state": state, "mtu": mtu, "ips": [], "ipv6": [], "zone": None, } for line in addr_out.splitlines(): if not line: continue parts = line.split() if len(parts) < 4: continue addr_name = parts[1] addr_key = "ipv6" if parts[2] == "inet6" else "ips" for entry in iface_map.values(): if entry["display_name"] == addr_name: entry[addr_key].append(parts[3]) break active = _parse_active_zones(zones_out) for zone_name, ifaces in active.items(): for raw_if in ifaces: clean = raw_if.partition("@")[0] for entry in iface_map.values(): if entry["display_name"] == clean or entry["name"] == raw_if: entry["zone"] = zone_name break return list(iface_map.values()) @registry.register("GET", "/firewall/zones", cache_tags=_READ_TAGS) def get_zones(_request: Any, _body: Any) -> dict[str, Any]: available = run(["firewall-cmd", "--get-zones"], sudo=True).split() active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True)) return {"active": active, "available": available} @registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS) def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body or "zone" not in body: raise ValueError("'zone' is required") zone = body["zone"] if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") raw = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) return _parse_zone_output(zone, raw) @registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS) def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]: active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True)) result: list[dict[str, Any]] = [] for zone_name in active: try: raw = run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True) result.append(_parse_zone_output(zone_name, raw)) except Exception: continue return result @registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS) def get_services(_request: Any, _body: Any) -> list[str]: return run(["firewall-cmd", "--get-services"], sudo=True).split() @registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS) def get_config(_request: Any, _body: Any) -> dict[str, Any]: return _get_config() @registry.register("POST", "/firewall/config", invalidate=_READ_TAGS) def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body or "zones" not in body: raise ValueError("'zones' key is required") if not isinstance(body["zones"], dict): raise ValueError("'zones' must be a dict") _save_config(body) logger.info("Firewall config saved (%d zones)", len(body["zones"])) return {"config_saved": True} @registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS) def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body must be a JSON object") from lib.common import deep_merge current = _get_config() merged = deep_merge(current, body) _save_config(merged) logger.info("Firewall config patched: %s", sorted(body.keys())) return {"config_saved": True} @registry.register("GET", "/firewall/config/pending", cache_tags=_READ_TAGS) def config_pending(_request: Any, _body: Any) -> dict[str, Any]: return _config_pending(_get_state()) @registry.register("POST", "/firewall/config/apply", invalidate=_READ_TAGS) def config_apply(_request: Any, _body: Any) -> dict[str, Any]: result = _config_apply() logger.info("Firewall config applied: %s", result.get("applied_zones", [])) return result @registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS) def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone_name = body.get("name", "").strip() target = body.get("target", "default").strip() or "default" if not zone_name: raise ValueError("Zone name is required") available = run(["firewall-cmd", "--get-zones"], sudo=True).split() if zone_name in available: raise ValueError(f"Zone '{zone_name}' already exists") run( [ "firewall-cmd", f"--zone={zone_name}", f"--set-target={target}", "--permanent", ], sudo=True, ) _reload() logger.info("Zone '%s' created (target=%s)", zone_name, target) return {"zone": zone_name} @registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS) def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body or "zone" not in body: raise ValueError("'zone' is required") zone = body["zone"] available = run(["firewall-cmd", "--get-zones"], sudo=True).split() if zone not in available: raise NotFoundError(f"Zone '{zone}' does not exist") run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) _reload() logger.info("Zone '%s' deleted", zone) return {"zone": zone} @registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS) def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() interfaces = body.get("interfaces", []) if not zone: raise ValueError("'zone' is required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") try: current = _parse_zone_output( zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) ).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) return {"zone": zone, "interfaces": interfaces} @registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS) def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() services = body.get("services", []) if not zone: raise ValueError("'zone' is required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") current = _parse_zone_output( zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) ).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() return {"zone": zone, "services": services} @registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS) def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() rule = body.get("rule", "").strip() if not zone or not rule: raise ValueError("'zone' and 'rule' are required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") from uuid import uuid4 run( [ "firewall-cmd", f"--zone={zone}", "--add-rich-rule=" + rule, "--permanent", ], sudo=True, ) _reload() cfg = _get_config() cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("rich_rules", []) rule_id = uuid4().hex[:8] entry = {"id": rule_id, "rule": rule} cfg["zones"][zone]["rich_rules"].append(entry) _save_config(cfg) return {"zone": zone, "id": rule_id, "rule": rule} @registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS) def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() rule_id = body.get("id", "").strip() if not zone or not rule_id: raise ValueError("'zone' and 'id' are required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") 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 NotFoundError(f"Rich rule '{rule_id}' not found in zone '{zone}'") rule = entry["rule"] run( [ "firewall-cmd", f"--zone={zone}", "--remove-rich-rule=" + rule, "--permanent", ], sudo=True, ) _reload() zone_cfg["rich_rules"] = [ r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id ] _save_config(cfg) return {"zone": zone, "id": rule_id} @registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS) def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]: if not body or "zone" not in body: raise ValueError("'zone' is required") zone = body["zone"] raw = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True) raw = raw.strip() if not raw: return [] rules: list[str] = [] current: list[str] = [] for line in raw.splitlines(): r = line.rstrip() if not r.endswith(";"): current.append(r) else: current.append(r) rules.append(" ".join(current)) current = [] if current: rules.append(" ".join(current)) cfg = _get_config() cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) result: list[dict[str, Any]] = [] for rule_str in rules: matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None) if matched: result.append({"id": matched["id"], "rule": rule_str}) else: result.append({"rule": rule_str}) return result @registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS) def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() enable = body.get("enable") if not zone or enable is None: raise ValueError("'zone' and 'enable' (bool) are required") action = "--add-masquerade" if enable else "--remove-masquerade" run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) _reload() return {"zone": zone, "masquerade": bool(enable)} @registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS) def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() port = body.get("port") proto = body.get("proto", "").strip() toaddr = body.get("toaddr") toport = body.get("toport") if not zone or port is None or not proto: raise ValueError("'zone', 'port', and 'proto' are required") from uuid import uuid4 fwd = f"port={port}/proto={proto}" if toaddr and toport: fwd += f"/toaddr={toaddr}/toport={toport}" elif toport: fwd += f"/toport={toport}" elif toaddr: fwd += f"/toaddr={toaddr}" run( [ "firewall-cmd", f"--zone={zone}", f"--add-forward-port={fwd}", "--permanent", ], sudo=True, ) _reload() fp_id = uuid4().hex[:8] entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto} if toaddr: entry["toaddr"] = toaddr if toport: entry["toport"] = int(toport) cfg = _get_config() cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", []) cfg["zones"][zone]["forward_ports"].append(entry) _save_config(cfg) return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto} @registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS) def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") zone = body.get("zone", "").strip() port = body.get("port") proto = body.get("proto", "").strip() if not zone or port is None or not proto: raise ValueError("'zone', 'port', and 'proto' are required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") fwd = f"port={port}/proto={proto}" cfg = _get_config() fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", []) found = False for fp in fps: if fp.get("port") == port and fp.get("proto") == proto: found = True if fp.get("toaddr") and fp.get("toport"): fwd += f"/toaddr={fp['toaddr']}/toport={fp['toport']}" elif fp.get("toport"): fwd += f"/toport={fp['toport']}" elif fp.get("toaddr"): fwd += f"/toaddr={fp['toaddr']}" break if not found: raise NotFoundError(f"Forward port {port}/{proto} not found in zone '{zone}'") run( [ "firewall-cmd", f"--zone={zone}", f"--remove-forward-port={fwd}", "--permanent", ], sudo=True, ) _reload() 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") == proto) ] _save_config(cfg) return {"zone": zone, "port": int(port), "proto": proto} @registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS) def get_state(_request: Any, _body: Any) -> dict[str, Any]: return _get_state()