faa076370d
- 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.
375 lines
11 KiB
Python
375 lines
11 KiB
Python
"""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/<name>", params={"zone": "name"}
|
|
)
|
|
def zone_details():
|
|
"""GET /api/firewall/zones/<name> — 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/<name>",
|
|
params={"zone": "name"},
|
|
transform=void_transform,
|
|
)
|
|
def delete_zone_bp():
|
|
"""DELETE /api/firewall/zones/<name> — Delete a firewall zone by name."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zone interfaces / services
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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."""
|
|
|
|
|
|
@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."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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/<zone>")
|
|
def list_rich_rules():
|
|
"""GET /api/firewall/rich-rules/<zone> — List rich rules for a zone."""
|
|
|
|
|
|
@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."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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/<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."""
|