"""Firewall (firewalld) management API blueprint. Exposed at /api/firewall/* and delegates all operations to vacuum-walld. """ import logging from typing import Any from flask import Blueprint from daemon.client import ( # noqa: F401 (resolved via module globals) delete, get, patch, post, ) from daemon.iface import ( DELETE_FIREWALL_FORWARD_PORT_REMOVE, DELETE_FIREWALL_RICH_RULES_REMOVE, DELETE_FIREWALL_ZONES_DELETE, GET_FIREWALL_CONFIG, GET_FIREWALL_CONFIG_PENDING, GET_FIREWALL_INTERFACES, GET_FIREWALL_RICH_RULES, GET_FIREWALL_SERVICES, GET_FIREWALL_STATE, GET_FIREWALL_ZONES, GET_FIREWALL_ZONES_INFO, PATCH_FIREWALL_CONFIG, POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_FIREWALL_FORWARD_PORT_ADD, POST_FIREWALL_MASQUERADE, POST_FIREWALL_RICH_RULES_ADD, POST_FIREWALL_ZONES_CREATE, POST_FIREWALL_ZONES_INTERFACES, POST_FIREWALL_ZONES_SERVICES, ) from webui.api.common import NO_BODY, daemon_route, require_dict_body, void_transform logger = logging.getLogger(__name__) bp = Blueprint("firewall", __name__) # --------------------------------------------------------------------------- # Body builders / prechecks / transforms # --------------------------------------------------------------------------- def _config_save_precheck(json: Any, _va: Any) -> None: body = json or {} if "zones" not in body: raise ValueError("'zones' key is required") if not isinstance(body["zones"], dict): raise ValueError("'zones' must be a dict") def _interfaces_precheck(json: Any, _va: Any) -> None: if not isinstance((json or {}).get("interfaces", []), list): raise ValueError("'interfaces' must be a list") def _services_precheck(json: Any, _va: Any) -> None: if not isinstance((json or {}).get("services", []), list): raise ValueError("'services' must be a list") def _pending_data() -> dict[str, Any] | None: try: pending = get(GET_FIREWALL_CONFIG_PENDING) return { "pending": pending.get("pending", []), "needs_apply": pending.get("needs_apply", False), "unmanaged_zones": pending.get("unmanaged_zones", {}), } except RuntimeError as exc: # The save already succeeded; the follow-up read is best-effort so a # failure degrades to a bare ``config_saved`` rather than a 500. logger.warning("Failed to read pending state after config save: %s", exc) return None def _config_saved(_data: Any, _va: Any, _sent: Any) -> Any: return {"config_saved": True, **(_pending_data() or {})} def _create_zone_body(request: Any, _va: Any) -> dict[str, Any]: body = request.get_json(silent=True) or {} zone_name = (body.get("name") or "").strip() if not zone_name: raise ValueError("Zone name is required") target = (body.get("target") or "").strip() or "default" return {"name": zone_name, "target": target} def _zones_list(data: Any, _va: Any, _sent: Any) -> Any: return {"active": data.get("active", {}), "available": data.get("available", [])} def _zone_interfaces_echo(_data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "interfaces": sent.get("interfaces", [])} def _zone_services_echo(_data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "services": sent.get("services", [])} def _add_rich_rule_body(request: Any, _va: Any) -> dict[str, Any]: body = request.get_json(silent=True) or {} zone = (body.get("zone") or "").strip() rule = (body.get("rule") or "").strip() if not zone or not rule: raise ValueError("Both 'zone' and 'rule' are required") return {"zone": zone, "rule": rule} def _rich_rule_add_echo(data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "id": data["id"], "rule": sent["rule"]} def _rich_rule_remove_echo(_data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "id": sent["id"]} def _masquerade_body(request: Any, _va: Any) -> dict[str, Any]: body = request.get_json(silent=True) or {} zone = (body.get("zone") or "").strip() enable = body.get("enable") if not zone or enable is None: raise ValueError("'zone' and 'enable' (bool) are required") return {"zone": zone, "enable": bool(enable)} def _masquerade_echo(_data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "masquerade": sent["enable"]} def _add_forward_port_body(request: Any, _va: Any) -> dict[str, Any]: body = request.get_json(silent=True) or {} zone = (body.get("zone") or "").strip() port = body.get("port") proto = (body.get("proto") or "").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") try: port_int = int(port) except ValueError: raise ValueError("'port' must be an integer") from None toport_int = None if toport is not None: try: toport_int = int(toport) except ValueError: raise ValueError("'toport' must be an integer") from None return { "zone": zone, "port": port_int, "proto": proto, "toaddr": str(toaddr) if toaddr else None, "toport": toport_int, } def _forward_port_add_echo(data: Any, _va: Any, sent: Any) -> Any: return { "zone": sent["zone"], "id": data["id"], "port": sent["port"], "proto": sent["proto"], } def _forward_port_remove_echo(_data: Any, _va: Any, sent: Any) -> Any: return {"zone": sent["zone"], "port": sent["port"], "proto": sent["proto"]} # --------------------------------------------------------------------------- # Declarative config (two-step: save -> apply) # --------------------------------------------------------------------------- @daemon_route(GET_FIREWALL_CONFIG, bp) def config_list(): """GET /api/firewall/config — Retrieve the current firewall config.""" @daemon_route( POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved ) def config_save(): """POST /api/firewall/config — Save a new firewall declarative configuration.""" @daemon_route( PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved ) def patch_config(): """PATCH /api/firewall/config — Partially update the firewall configuration.""" @daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY) def config_apply_bp(): """POST /api/firewall/config/apply — Apply pending firewall config changes.""" @daemon_route(GET_FIREWALL_CONFIG_PENDING, bp) def config_pending_bp(): """GET /api/firewall/config/pending — Check the pending firewall config state.""" # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- @daemon_route(GET_FIREWALL_STATE, bp) def get_state(): """GET /api/firewall/state — Retrieve current firewall state.""" # --------------------------------------------------------------------------- # Zones # --------------------------------------------------------------------------- @daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list) def list_zones(): """GET /api/firewall/zones — List active and available firewall zones.""" @daemon_route( GET_FIREWALL_ZONES_INFO, bp, rule="/zones/", params={"zone": "name"} ) def zone_details(): """GET /api/firewall/zones/ — Retrieve details for a specific zone.""" @daemon_route( POST_FIREWALL_ZONES_CREATE, bp, rule="/zones", body=_create_zone_body, transform=void_transform, ) def create_zone_bp(): """POST /api/firewall/zones — Create a new firewall zone.""" @daemon_route( DELETE_FIREWALL_ZONES_DELETE, bp, rule="/zones/", params={"zone": "name"}, transform=void_transform, ) def delete_zone_bp(): """DELETE /api/firewall/zones/ — Delete a firewall zone by name.""" # --------------------------------------------------------------------------- # Zone interfaces / services # --------------------------------------------------------------------------- @daemon_route( POST_FIREWALL_ZONES_INTERFACES, bp, rule="/zones//interfaces", params={"zone": "name"}, precheck=_interfaces_precheck, transform=_zone_interfaces_echo, ) def set_zone_interfaces_bp(): """POST /api/firewall/zones//interfaces — Set a zone's interfaces.""" @daemon_route( POST_FIREWALL_ZONES_SERVICES, bp, rule="/zones//services", params={"zone": "name"}, precheck=_services_precheck, transform=_zone_services_echo, ) def set_zone_services_bp(): """POST /api/firewall/zones//services — Set a zone's allowed services.""" # --------------------------------------------------------------------------- # Available services and interfaces # --------------------------------------------------------------------------- @daemon_route(GET_FIREWALL_SERVICES, bp) def list_services(): """GET /api/firewall/services — List all available firewall services.""" @daemon_route(GET_FIREWALL_INTERFACES, bp) def list_interfaces(): """GET /api/firewall/interfaces — List all available network interfaces.""" # --------------------------------------------------------------------------- # Rich rules # --------------------------------------------------------------------------- @daemon_route( POST_FIREWALL_RICH_RULES_ADD, bp, rule="/rich-rules", body=_add_rich_rule_body, transform=_rich_rule_add_echo, ) def add_rich_rule_bp(): """POST /api/firewall/rich-rules — Add a rich rule to a firewall zone.""" @daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/") def list_rich_rules(): """GET /api/firewall/rich-rules/ — List rich rules for a zone.""" @daemon_route( DELETE_FIREWALL_RICH_RULES_REMOVE, bp, rule="/rich-rules//", params={"id": "rule_id"}, transform=_rich_rule_remove_echo, ) def remove_rich_rule_bp(): """DELETE /api/firewall/rich-rules// — Remove a rich rule by ID.""" # --------------------------------------------------------------------------- # Masquerade (NAT) # --------------------------------------------------------------------------- @daemon_route( POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo ) def set_masquerade_bp(): """POST /api/firewall/masquerade — Enable or disable masquerade (NAT).""" # --------------------------------------------------------------------------- # Port forwarding # --------------------------------------------------------------------------- @daemon_route( POST_FIREWALL_FORWARD_PORT_ADD, bp, rule="/forward-port", body=_add_forward_port_body, transform=_forward_port_add_echo, ) def add_forward_port_bp(): """POST /api/firewall/forward-port — Add a port forwarding rule to a zone.""" @daemon_route( DELETE_FIREWALL_FORWARD_PORT_REMOVE, bp, rule="/forward-port///", transform=_forward_port_remove_echo, ) def remove_forward_port_bp(): """DELETE /api/firewall/forward-port/// — Remove a rule."""