""" webui/api/firewall.py - Firewall (firewalld) management API blueprint. Exposed at /api/firewall/* and delegates all mutations to lib.firewall. """ import logging 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_by_id, remove_rich_rule_by_id, set_masquerade, set_zone_interfaces, set_zone_services, ) logger = logging.getLogger(__name__) 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: logger.error("Failed to read firewall config: %s", 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() logger.info("Firewall config saved (%d zones)", len(body["zones"])) 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: logger.error("Failed to save firewall config: %s", 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() logger.info("Firewall config applied: %s", result.get("applied_zones", [])) return _ok(result) except Exception as exc: logger.error("Failed to apply firewall config: %s", 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: logger.error("Failed to check pending config: %s", 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 _ok({"active": active, "available": available}) except RuntimeError as exc: logger.error("Failed to list zones: %s", 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 _ok(info) except RuntimeError as exc: logger.error("Failed to get zone '%s' info: %s", name, 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) logger.info("Zone '%s' created via API", zone_name) return _ok(None) except RuntimeError as exc: logger.error("Failed to create zone '%s': %s", zone_name, 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) logger.info("Zone '%s' deleted via API", name) return _ok(None) except RuntimeError as exc: logger.error("Failed to delete zone '%s': %s", name, 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) logger.info("Zone '%s' interfaces updated: %s", name, interfaces) return _ok({"zone": name, "interfaces": interfaces}) except RuntimeError as exc: logger.error("Failed to set interfaces for zone '%s': %s", name, 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: logger.error("Failed to set services for zone '%s': %s", name, 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: logger.error("Failed to list services: %s", exc) return _error(str(exc), 500) @bp.route("/interfaces", methods=["GET"]) def list_interfaces(): try: return _ok(get_interfaces()) except RuntimeError as exc: logger.error("Failed to list interfaces: %s", 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: entry = add_rich_rule(zone, rule) logger.info("Rich rule added to zone '%s': %s", zone, rule[:80]) return _ok({"zone": zone, "id": entry["id"], "rule": rule}) except RuntimeError as exc: logger.error("Failed to add rich rule to zone '%s': %s", zone, exc) return _error(str(exc), 500) @bp.route("/rich-rules/", methods=["GET"]) def list_rich_rules(zone): try: rules = get_rich_rules(zone) from lib.firewall import config_get as firewall_config_get cfg = firewall_config_get() cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) result = [] 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 _ok(result) except RuntimeError as exc: logger.error("Failed to get rich rules for zone '%s': %s", zone, exc) return _error(str(exc), 500) @bp.route("/rich-rules//", methods=["DELETE"]) def remove_rich_rule_bp(zone, rule_id): try: remove_rich_rule_by_id(zone, rule_id) logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone) return _ok({"zone": zone, "id": rule_id}) except ValueError as exc: return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove rich rule from zone '%s': %s", zone, 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)) logger.info( "Masquerade %s on zone '%s' via API", "enabled" if enable else "disabled", zone, ) return _ok({"zone": zone, "masquerade": bool(enable)}) except RuntimeError as exc: logger.error("Failed to set masquerade on zone '%s': %s", zone, 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: entry = 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, "id": entry["id"], "port": int(port), "proto": proto} ) except (ValueError, RuntimeError) as exc: code = 400 if isinstance(exc, ValueError) else 500 logger.error("Failed to add forward port: %s", exc) return _error(str(exc), code) @bp.route("/forward-port///", methods=["DELETE"]) def remove_forward_port_bp(zone, port, proto): try: remove_forward_port_by_id(zone, port, proto) logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone) return _ok({"zone": zone, "port": port, "proto": proto}) except ValueError as exc: return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove forward port from zone '%s': %s", zone, exc) return _error(str(exc), 500)