refactor: daemon collectors, thin webui proxies, pure config reads

- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+249 -524
View File
@@ -4,10 +4,16 @@ Exposed at /api/firewall/* and delegates all operations to vacuum-walld.
"""
import logging
from typing import Any
from flask import Blueprint, request
from flask import Blueprint
from daemon.client import BadRequest, NotFound, delete, get, patch, post
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,
@@ -30,169 +36,179 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from webui.api.common import _error, _ok
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)
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG, bp)
def config_list():
"""Retrieve the current firewall declarative configuration.
Returns JSON containing the full firewall config from the daemon.
Endpoint:
GET /api/firewall/config
Returns:
JSON response with the config data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG))
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config — Retrieve the current firewall config."""
@bp.route("/config", methods=["POST"])
@daemon_route(
POST_FIREWALL_CONFIG, bp, precheck=_config_save_precheck, transform=_config_saved
)
def config_save():
"""Save a new firewall declarative configuration.
Validates that the request body contains a ``zones`` dict, forwards
to the daemon, and returns the pending state including unmanaged zones.
Endpoint:
POST /api/firewall/config
Args:
body: JSON with ``zones`` dict mapping zone names to zone configs.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
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:
post(POST_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config save: %s", exc)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config save rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config — Save a new firewall declarative configuration."""
@bp.route("/config", methods=["PATCH"])
@daemon_route(
PATCH_FIREWALL_CONFIG, bp, precheck=require_dict_body, transform=_config_saved
)
def patch_config():
"""Partially update the firewall declarative configuration.
Accepts a JSON body and forwards it as a patch to the daemon config
endpoint, returning the updated pending state.
Endpoint:
PATCH /api/firewall/config
Args:
body: JSON object with configuration fields to patch.
Returns:
JSON with ``config_saved`` flag and pending apply information.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_FIREWALL_CONFIG, body)
try:
pending = get(GET_FIREWALL_CONFIG_PENDING)
pending_data = {
"pending": pending.get("pending", []),
"needs_apply": pending.get("needs_apply", False),
"unmanaged_zones": pending.get("unmanaged_zones", {}),
}
except RuntimeError as exc:
pending_data = None
logger.warning("Failed to read pending state after config patch: %s", exc)
return _ok(
{
"config_saved": True,
**(pending_data or {}),
}
)
except BadRequest as exc:
logger.info("Firewall config patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch firewall config: %s", exc)
return _error(str(exc), 500)
"""PATCH /api/firewall/config — Partially update the firewall configuration."""
@bp.route("/config/apply", methods=["POST"])
@daemon_route(POST_FIREWALL_CONFIG_APPLY, bp, body=NO_BODY)
def config_apply_bp():
"""Apply any pending firewall configuration changes.
Triggers the daemon to apply saved declarative config to the live
firewalld instance.
Endpoint:
POST /api/firewall/config/apply
Returns:
JSON with ``applied_zones`` list or an error message.
"""
try:
result = post(POST_FIREWALL_CONFIG_APPLY)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/config/apply — Apply pending firewall config changes."""
@bp.route("/config/pending", methods=["GET"])
@daemon_route(GET_FIREWALL_CONFIG_PENDING, bp)
def config_pending_bp():
"""Check the pending firewall configuration state.
Returns information about unsaved changes, whether an apply is
needed, and any unmanaged zones detected on the system.
Endpoint:
GET /api/firewall/config/pending
Returns:
JSON with pending changes and apply status.
"""
try:
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/config/pending — Check the pending firewall config state."""
# ---------------------------------------------------------------------------
@@ -200,21 +216,9 @@ def config_pending_bp():
# ---------------------------------------------------------------------------
@bp.route("/state", methods=["GET"])
@daemon_route(GET_FIREWALL_STATE, bp)
def get_state():
"""Retrieve current firewall state from the state store.
Endpoint:
GET /api/firewall/state
Returns:
JSON with firewall state data or an error message.
"""
try:
return _ok(get(GET_FIREWALL_STATE))
except RuntimeError as exc:
logger.error("Failed to get firewall state: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/state — Retrieve current firewall state."""
# ---------------------------------------------------------------------------
@@ -222,182 +226,67 @@ def get_state():
# ---------------------------------------------------------------------------
@bp.route("/zones", methods=["GET"])
@daemon_route(GET_FIREWALL_ZONES, bp, transform=_zones_list)
def list_zones():
"""List all active and available firewall zones.
Endpoint:
GET /api/firewall/zones
Returns:
JSON with ``active`` zones dict and ``available`` zones list.
"""
try:
data = get(GET_FIREWALL_ZONES)
return _ok(
{"active": data.get("active", {}), "available": data.get("available", [])}
)
except RuntimeError as exc:
logger.error("Failed to list zones: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/zones — List active and available firewall zones."""
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name: str):
"""Retrieve details for a specific firewall zone.
Endpoint:
GET /api/firewall/zones/<name>
Args:
name: Name of the zone to look up.
Returns:
JSON with zone configuration details or 404 error.
"""
try:
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
return _ok(info)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
GET_FIREWALL_ZONES_INFO, bp, rule="/zones/<name>", params={"zone": "name"}
)
def zone_details():
"""GET /api/firewall/zones/<name> — Retrieve details for a specific zone."""
@bp.route("/zones", methods=["POST"])
@daemon_route(
POST_FIREWALL_ZONES_CREATE,
bp,
rule="/zones",
body=_create_zone_body,
transform=void_transform,
)
def create_zone_bp():
"""Create a new firewall zone.
Endpoint:
POST /api/firewall/zones
Args:
body: JSON with ``name`` (required) and optional ``target`` string.
Returns:
JSON confirmation or error if the zone already exists.
"""
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:
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
logger.info("Zone '%s' created via API", zone_name)
return _ok(None)
except BadRequest as exc:
logger.info("Zone '%s' creation rejected: %s", zone_name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to create zone '%s': %s", zone_name, exc)
return _error(str(exc), 500)
"""POST /api/firewall/zones — Create a new firewall zone."""
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name: str):
"""Delete a firewall zone by name.
Endpoint:
DELETE /api/firewall/zones/<name>
Args:
name: Name of the zone to delete.
Returns:
JSON confirmation or 404 if the zone does not exist.
"""
try:
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
logger.info("Zone '%s' deleted via API", name)
return _ok(None)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to delete zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
DELETE_FIREWALL_ZONES_DELETE,
bp,
rule="/zones/<name>",
params={"zone": "name"},
transform=void_transform,
)
def delete_zone_bp():
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
# ---------------------------------------------------------------------------
# Zone interfaces
# Zone interfaces / services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name: str):
"""Set the network interfaces assigned to a firewall zone.
Replaces all existing interfaces for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/interfaces
Args:
name: Zone name.
body: JSON with ``interfaces`` list of interface names.
Returns:
JSON confirmation with zone and updated interfaces list.
"""
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:
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except BadRequest as exc:
logger.info("Set interfaces for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_INTERFACES,
bp,
rule="/zones/<name>/interfaces",
params={"zone": "name"},
precheck=_interfaces_precheck,
transform=_zone_interfaces_echo,
)
def set_zone_interfaces_bp():
"""POST /api/firewall/zones/<name>/interfaces — Set a zone's interfaces."""
# ---------------------------------------------------------------------------
# Zone services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name: str):
"""Set the allowed services for a firewall zone.
Replaces all existing services for the zone with the provided list.
Endpoint:
POST /api/firewall/zones/<name>/services
Args:
name: Zone name.
body: JSON with ``services`` list of service names.
Returns:
JSON confirmation with zone and updated services list.
"""
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:
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
return _ok({"zone": name, "services": services})
except BadRequest as exc:
logger.info("Set services for zone '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Zone '%s' not found: %s", name, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@daemon_route(
POST_FIREWALL_ZONES_SERVICES,
bp,
rule="/zones/<name>/services",
params={"zone": "name"},
precheck=_services_precheck,
transform=_zone_services_echo,
)
def set_zone_services_bp():
"""POST /api/firewall/zones/<name>/services — Set a zone's allowed services."""
# ---------------------------------------------------------------------------
@@ -405,38 +294,14 @@ def set_zone_services_bp(name: str):
# ---------------------------------------------------------------------------
@bp.route("/services", methods=["GET"])
@daemon_route(GET_FIREWALL_SERVICES, bp)
def list_services():
"""List all available firewall services.
Endpoint:
GET /api/firewall/services
Returns:
JSON with the list of available service names.
"""
try:
return _ok(get(GET_FIREWALL_SERVICES))
except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/services — List all available firewall services."""
@bp.route("/interfaces", methods=["GET"])
@daemon_route(GET_FIREWALL_INTERFACES, bp)
def list_interfaces():
"""List all available network interfaces.
Endpoint:
GET /api/firewall/interfaces
Returns:
JSON with the list of available interface names.
"""
try:
return _ok(get(GET_FIREWALL_INTERFACES))
except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500)
"""GET /api/firewall/interfaces — List all available network interfaces."""
# ---------------------------------------------------------------------------
@@ -444,80 +309,31 @@ def list_interfaces():
# ---------------------------------------------------------------------------
@bp.route("/rich-rules", methods=["POST"])
@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():
"""Add a rich rule to a firewall zone.
Endpoint:
POST /api/firewall/rich-rules
Args:
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
Returns:
JSON with zone, generated rule ID, and rule string.
"""
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 = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
except BadRequest as exc:
logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/rich-rules — Add a rich rule to a firewall zone."""
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone: str):
"""List rich rules for a specific firewall zone.
Endpoint:
GET /api/firewall/rich-rules/<zone>
Args:
zone: Zone name to list rules for.
Returns:
JSON with list of rich rule entries for the zone.
"""
try:
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@daemon_route(GET_FIREWALL_RICH_RULES, bp, rule="/rich-rules/<zone>")
def list_rich_rules():
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone: str, rule_id: str):
"""Remove a rich rule from a firewall zone by ID.
Endpoint:
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
Args:
zone: Zone name.
rule_id: Rule identifier.
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except NotFound as exc:
logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, 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)
@daemon_route(
DELETE_FIREWALL_RICH_RULES_REMOVE,
bp,
rule="/rich-rules/<zone>/<rule_id>",
params={"id": "rule_id"},
transform=_rich_rule_remove_echo,
)
def remove_rich_rule_bp():
"""DELETE /api/firewall/rich-rules/<zone>/<rule_id> — Remove a rich rule by ID."""
# ---------------------------------------------------------------------------
@@ -525,38 +341,11 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
# ---------------------------------------------------------------------------
@bp.route("/masquerade", methods=["POST"])
@daemon_route(
POST_FIREWALL_MASQUERADE, bp, body=_masquerade_body, transform=_masquerade_echo
)
def set_masquerade_bp():
"""Enable or disable masquerade (NAT) on a firewall zone.
Endpoint:
POST /api/firewall/masquerade
Args:
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
Returns:
JSON confirmation with zone and masquerade status.
"""
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:
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": 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 BadRequest as exc:
logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
return _error(str(exc), 500)
"""POST /api/firewall/masquerade — Enable or disable masquerade (NAT)."""
# ---------------------------------------------------------------------------
@@ -564,86 +353,22 @@ def set_masquerade_bp():
# ---------------------------------------------------------------------------
@bp.route("/forward-port", methods=["POST"])
@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():
"""Add a port forwarding rule to a firewall zone.
Endpoint:
POST /api/firewall/forward-port
Args:
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
(tcp/udp), optional ``toaddr`` and ``toport``.
Returns:
JSON confirmation with zone, generated ID, port, and protocol.
"""
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:
port_int = int(port)
except ValueError:
return _error("'port' must be an integer", 400)
toport_int = None
if toport is not None:
try:
toport_int = int(toport)
except ValueError:
return _error("'toport' must be an integer", 400)
toaddr_str = str(toaddr) if toaddr else None
try:
entry = post(
POST_FIREWALL_FORWARD_PORT_ADD,
{
"zone": zone,
"port": port_int,
"proto": proto,
"toaddr": toaddr_str,
"toport": toport_int,
},
)
return _ok({"zone": zone, "id": entry["id"], "port": port_int, "proto": proto})
except BadRequest as exc:
logger.info("Add forward port rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add forward port: %s", exc)
return _error(str(exc), 500)
"""POST /api/firewall/forward-port — Add a port forwarding rule to a zone."""
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone: str, port: int, proto: str):
"""Remove a port forwarding rule from a firewall zone.
Endpoint:
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
Args:
zone: Zone name.
port: Port number.
proto: Protocol (tcp/udp).
Returns:
JSON confirmation or 404 if the rule does not exist.
"""
try:
delete(
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
{"zone": zone, "port": port, "proto": proto},
)
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
return _ok({"zone": zone, "port": port, "proto": proto})
except NotFound as exc:
logger.info(
"Forward port %s/%s not found in zone '%s': %s", port, proto, zone, 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)
@daemon_route(
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
bp,
rule="/forward-port/<zone>/<int:port>/<proto>",
transform=_forward_port_remove_echo,
)
def remove_forward_port_bp():
"""DELETE /api/firewall/forward-port/<zone>/<port>/<proto> — Remove a rule."""