""" webui/api/firewall.py - Firewall (firewalld) management API blueprint. Exposed at /api/firewall/* and delegates all mutations to lib.firewall. """ from flask import Blueprint, jsonify, request from lib.firewall import ( add_forward_port, add_rich_rule, config_get, config_pending, config_set, create_zone, delete_zone, get_active_zones, get_available_zones, get_interfaces, get_rich_rules, get_services, get_zone_info, remove_forward_port, remove_rich_rule, set_masquerade, set_zone_interfaces, set_zone_services, ) bp = Blueprint("firewall", __name__) # --------------------------------------------------------------------------- # Error helpers # --------------------------------------------------------------------------- def _error(msg, code=400): return jsonify({"ok": False, "error": msg}), code def _ok(data=None): return jsonify({"ok": True, "data": data}) # --------------------------------------------------------------------------- # Declarative config (two-step: save -> apply) # --------------------------------------------------------------------------- @bp.route("/config", methods=["GET"]) def config_get_bp(): try: return _ok(config_get()) except Exception as exc: return _error(str(exc), 500) @bp.route("/config", methods=["POST"]) def config_set_bp(): body = request.get_json(silent=True) or {} if "zones" not in body: return _error("'zones' key is required", 400) if not isinstance(body["zones"], dict): return _error("'zones' must be a dict", 400) try: config_set(body) pending_info = config_pending() return _ok( { "config_saved": True, "pending": pending_info["pending"], "needs_apply": pending_info["needs_apply"], "unmanaged_zones": pending_info.get("unmanaged_zones", {}), } ) except Exception as exc: return _error(str(exc), 500) @bp.route("/config/apply", methods=["POST"]) def config_apply_bp(): try: from lib.firewall import config_apply as _config_apply result = _config_apply() return _ok(result) except Exception as exc: return _error(str(exc), 500) @bp.route("/config/pending", methods=["GET"]) def config_pending_bp(): try: return _ok(config_pending()) except Exception as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Zones # --------------------------------------------------------------------------- @bp.route("/zones", methods=["GET"]) def list_zones(): try: active = get_active_zones() available = get_available_zones() return jsonify( { "ok": True, "data": { "active": active, "available": available, }, } ) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/zones/", methods=["GET"]) def zone_details(name): try: if name not in get_available_zones(): return _error(f"Zone '{name}' does not exist", 404) info = get_zone_info(name) return jsonify({"ok": True, "data": info}) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/zones", methods=["POST"]) def create_zone_bp(): body = request.get_json(silent=True) or {} zone_name = body.get("name", "").strip() target = body.get("target", "default").strip() or "default" if not zone_name: return _error("Zone name is required", 400) try: if zone_name in get_available_zones(): return _error(f"Zone '{zone_name}' already exists", 400) create_zone(zone_name, target) return _ok(None) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/zones/", methods=["DELETE"]) def delete_zone_bp(name): try: available = get_available_zones() if name not in available: return _error(f"Zone '{name}' does not exist", 404) delete_zone(name) return _ok(None) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Zone interfaces # --------------------------------------------------------------------------- @bp.route("/zones//interfaces", methods=["POST"]) def set_zone_interfaces_bp(name): body = request.get_json(silent=True) or {} interfaces = body.get("interfaces", []) if not isinstance(interfaces, list): return _error("'interfaces' must be a list", 400) try: set_zone_interfaces(name, interfaces) return _ok({"zone": name, "interfaces": interfaces}) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Zone services # --------------------------------------------------------------------------- @bp.route("/zones//services", methods=["POST"]) def set_zone_services_bp(name): body = request.get_json(silent=True) or {} services = body.get("services", []) if not isinstance(services, list): return _error("'services' must be a list", 400) try: set_zone_services(name, services) return _ok({"zone": name, "services": services}) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Available services and interfaces # --------------------------------------------------------------------------- @bp.route("/services", methods=["GET"]) def list_services(): try: return _ok(get_services()) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/interfaces", methods=["GET"]) def list_interfaces(): try: return _ok(get_interfaces()) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Rich rules # --------------------------------------------------------------------------- @bp.route("/rich-rules", methods=["POST"]) def add_rich_rule_bp(): body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() rule = body.get("rule", "").strip() if not zone or not rule: return _error("Both 'zone' and 'rule' are required", 400) try: add_rich_rule(zone, rule) return _ok({"zone": zone, "rule": rule}) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/rich-rules", methods=["DELETE"]) def remove_rich_rule_bp(): body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() rule = body.get("rule", "").strip() if not zone or not rule: return _error("Both 'zone' and 'rule' are required", 400) try: remove_rich_rule(zone, rule) return _ok({"zone": zone, "rule": rule}) except RuntimeError as exc: return _error(str(exc), 500) @bp.route("/rich-rules/", methods=["GET"]) def list_rich_rules(zone): try: rules = get_rich_rules(zone) return _ok(rules) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Masquerade (NAT) # --------------------------------------------------------------------------- @bp.route("/masquerade", methods=["POST"]) def set_masquerade_bp(): body = request.get_json(silent=True) or {} zone = body.get("zone", "").strip() enable = body.get("enable") if not zone or enable is None: return _error("'zone' and 'enable' (bool) are required", 400) try: set_masquerade(zone, bool(enable)) return _ok({"zone": zone, "masquerade": bool(enable)}) except RuntimeError as exc: return _error(str(exc), 500) # --------------------------------------------------------------------------- # Port forwarding # --------------------------------------------------------------------------- @bp.route("/forward-port", methods=["POST"]) def add_forward_port_bp(): body = request.get_json(silent=True) or {} 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: return _error("'zone', 'port', and 'proto' are required", 400) try: add_forward_port( zone, int(port), proto, toaddr=str(toaddr) if toaddr else None, toport=int(toport) if toport else None, ) return _ok({"zone": zone, "port": int(port), "proto": proto}) except (ValueError, RuntimeError) as exc: code = 400 if isinstance(exc, ValueError) else 500 return _error(str(exc), code) @bp.route("/forward-port", methods=["DELETE"]) def remove_forward_port_bp(): body = request.get_json(silent=True) or {} 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: return _error("'zone', 'port', and 'proto' are required", 400) try: remove_forward_port( zone, int(port), proto, toaddr=str(toaddr) if toaddr else None, toport=int(toport) if toport else None, ) return _ok({"zone": zone, "port": int(port), "proto": proto}) except (ValueError, RuntimeError) as exc: code = 400 if isinstance(exc, ValueError) else 500 return _error(str(exc), code)