332d14e37d
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
1188 lines
40 KiB
Python
1188 lines
40 KiB
Python
"""Firewall daemon handler.
|
|
|
|
Reads from the pre-computed state for status endpoints. Executes
|
|
firewall-cmd with sudo for mutations. Refers state after each mutation.
|
|
"""
|
|
|
|
import logging
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
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_ALL,
|
|
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 daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
|
from lib.common import load_json, run, save_json
|
|
from lib.firewall import (
|
|
_normalize_target,
|
|
_parse_active_zones,
|
|
_parse_zone_output,
|
|
fw_change_summary,
|
|
)
|
|
from lib.firewall import (
|
|
save_backup as _save_backup,
|
|
)
|
|
from lib.sync import SyncEvent, bus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
|
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
|
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
DEFAULT_CONFIG = {"zones": {}}
|
|
|
|
|
|
def _get_state() -> dict[str, Any] | None:
|
|
"""Return the current firewall state from the state store."""
|
|
from lib.state import state as state_store
|
|
|
|
return state_store.get("firewall")
|
|
|
|
|
|
def _ensure_config_file() -> None:
|
|
"""Initialize config file with defaults if missing."""
|
|
if not CONFIG_FILE.exists():
|
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
|
|
|
|
|
def _get_config() -> dict[str, Any]:
|
|
"""Load the firewall config file."""
|
|
_ensure_config_file()
|
|
return load_json(CONFIG_FILE)
|
|
|
|
|
|
def _save_config(cfg: dict[str, Any]) -> None:
|
|
"""Persist firewall config to disk."""
|
|
_ensure_config_file()
|
|
save_json(CONFIG_FILE, cfg, indent=2)
|
|
|
|
|
|
def _reload() -> None:
|
|
"""Reload firewalld to apply permanent changes."""
|
|
run(["firewall-cmd", "--reload"], sudo=True)
|
|
|
|
|
|
def _default_zone() -> str:
|
|
"""Return the firewalld default zone name.
|
|
|
|
The default zone is the catch-all for any interface without an explicit
|
|
zone assignment (including VPN interfaces), so it normally fronts the WAN.
|
|
"""
|
|
return run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
|
|
|
|
|
def _would_remove_mgmt(zone: str, services: list[str]) -> bool:
|
|
"""Return True if *services* lacks both https and ssh on the default zone.
|
|
|
|
The default zone fronts unassigned (WAN/VPN) interfaces, so removing both
|
|
management access (https via nginx) and remote recovery (ssh) from it
|
|
would leave no path back except a physical console.
|
|
"""
|
|
if "https" in services or "ssh" in services:
|
|
return False
|
|
try:
|
|
default = _default_zone()
|
|
except Exception:
|
|
logger.warning(
|
|
"Could not determine the firewalld default zone; failing closed for %s",
|
|
zone,
|
|
)
|
|
return True
|
|
return zone == default
|
|
|
|
|
|
def _fp_to_str(fp: dict[str, Any]) -> str:
|
|
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
|
|
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
|
if "toaddr" in fp:
|
|
parts.append(f"toaddr={fp['toaddr']}")
|
|
if "toport" in fp:
|
|
parts.append(f"toport={fp['toport']}")
|
|
return "/".join(parts)
|
|
|
|
|
|
def _get_forward_ports(zone_name: str) -> list[str]:
|
|
"""Return forward-port entries for a zone as CLI-style strings."""
|
|
with suppress(Exception):
|
|
fps = _parse_zone_output(
|
|
zone_name,
|
|
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
|
).get("forward-ports", [])
|
|
return [_fp_to_str(fp) for fp in fps if isinstance(fp, dict)]
|
|
return []
|
|
|
|
|
|
def _config_apply(force: bool = False) -> dict[str, Any]:
|
|
"""Apply saved declarative config to live firewalld.
|
|
|
|
For each zone in the config, reconciles interfaces, services, target,
|
|
masquerade, rich rules, and forward ports by removing old values first,
|
|
then adding desired values. Reloads firewalld at the end.
|
|
|
|
With *force* False (default), a ``ConflictError`` is raised before any
|
|
mutation if the config would strip both https and ssh from the default
|
|
zone; pass ``force=True`` to override.
|
|
"""
|
|
from lib.firewall import get_config as _get_lib_config
|
|
|
|
cfg = _get_lib_config()
|
|
cfg_zones = cfg.get("zones", {})
|
|
|
|
if not force:
|
|
default_zone = _default_zone()
|
|
lockout_zones = [
|
|
zn
|
|
for zn, zc in cfg_zones.items()
|
|
if zn == default_zone
|
|
and "https" not in zc.get("services", [])
|
|
and "ssh" not in zc.get("services", [])
|
|
]
|
|
if lockout_zones:
|
|
raise ConflictError(
|
|
f"Refusing to remove both https and ssh from default zone(s) "
|
|
f"{', '.join(repr(z) for z in lockout_zones)}: management access "
|
|
f"and remote recovery would be lost. Add at least one of them "
|
|
f'to the zone\'s services, or pass {{"force": true}}.'
|
|
)
|
|
|
|
full_state: dict[str, Any] = {
|
|
"active_zones": {},
|
|
"interfaces": [],
|
|
"available_services": [],
|
|
"zones": {},
|
|
"rich_rules": {},
|
|
"timestamp": "",
|
|
}
|
|
_save_backup(full_state)
|
|
|
|
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
|
applied: list[str] = []
|
|
for zone_name, zone_cfg in cfg_zones.items():
|
|
# Two-step reconciliation: remove current values, then add desired values.
|
|
# This ensures idempotency — running apply twice produces the same result.
|
|
need_create = zone_name not in available
|
|
|
|
if need_create:
|
|
# Create new zone first (--new-zone is required before --set-target)
|
|
run(
|
|
["firewall-cmd", f"--new-zone={zone_name}", "--permanent"],
|
|
sudo=True,
|
|
)
|
|
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
|
if target != "default":
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--set-target={target}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
else:
|
|
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
|
if desired_target != "default":
|
|
with suppress(RuntimeError):
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--set-target={desired_target}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
|
|
# Step 2: Reconcile services — remove all current, add desired list.
|
|
# Firewall-cmd doesn't have a "set-services" bulk operation, so we
|
|
# remove each existing service and then add each desired service.
|
|
current_svcs: list[str] = []
|
|
with suppress(Exception):
|
|
current_svcs = _parse_zone_output(
|
|
zone_name,
|
|
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
|
).get("services", [])
|
|
for svc in current_svcs:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--remove-service={svc}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
for svc in zone_cfg.get("services", []):
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-service={svc}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
|
|
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
|
current_ifaces: list[str] = []
|
|
with suppress(Exception):
|
|
current_ifaces = _parse_zone_output(
|
|
zone_name,
|
|
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
|
).get("interfaces", [])
|
|
for iface in current_ifaces:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
"--remove-interface=" + iface,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
for iface in zone_cfg.get("interfaces", []):
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
"--add-interface=" + iface,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
|
|
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
|
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
|
if zone_name != "public":
|
|
mq = zone_cfg.get("masquerade", False)
|
|
if mq is not None:
|
|
action = "--add-masquerade" if mq else "--remove-masquerade"
|
|
run(
|
|
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
|
|
sudo=True,
|
|
)
|
|
|
|
# Step 5: Reconcile rich rules — remove all current, add desired.
|
|
# Note: firewall-cmd doesn't track rule IDs for rich rules, so we
|
|
# must remove by full rule string match.
|
|
current_rules = _parse_zone_output(
|
|
zone_name,
|
|
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
|
).get("rich-rules", [])
|
|
for rule_str in current_rules:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--remove-rich-rule={rule_str}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
|
|
for rule_entry in zone_cfg.get("rich_rules", []):
|
|
rule_str = (
|
|
rule_entry.get("rule", "")
|
|
if isinstance(rule_entry, dict)
|
|
else str(rule_entry)
|
|
)
|
|
if rule_str:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-rich-rule={rule_str}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
|
|
# Step 6: Reconcile forward ports — same pattern.
|
|
current_fps = _get_forward_ports(zone_name)
|
|
for fp_str in current_fps:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--remove-forward-port={fp_str}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
for fp_entry in zone_cfg.get("forward_ports", []):
|
|
fp_str = fp_entry if isinstance(fp_entry, str) else _fp_to_str(fp_entry)
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-forward-port={fp_str}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
|
|
applied.append(zone_name)
|
|
|
|
# Step 7: Ensure masquerade propagation for nftables backend.
|
|
# With firewalld's nftables backend, POSTROUTING policy chains route traffic
|
|
# to the OUTPUT interface's zone chain. Traffic from internal zones (eth1)
|
|
# exiting through public (eth0) hits public's POSTROUTING chain, not
|
|
# internal's. If any non-public zone has masquerade enabled but the public
|
|
# zone doesn't, NAT silently fails — so propagate masquerade to public.
|
|
_any_non_public_mq = any(
|
|
z.get("masquerade", False) for zn, z in cfg_zones.items() if zn != "public"
|
|
)
|
|
_public_mq = cfg_zones.get("public", {}).get("masquerade", False)
|
|
if _any_non_public_mq and not _public_mq:
|
|
logger.info("Propagating masquerade to public zone for nftables compatibility")
|
|
run(
|
|
["firewall-cmd", "--zone=public", "--add-masquerade", "--permanent"],
|
|
sudo=True,
|
|
)
|
|
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = True
|
|
_save_config(cfg)
|
|
elif not _any_non_public_mq and _public_mq:
|
|
logger.info("No non-public zone needs masquerade, removing from public zone")
|
|
run(
|
|
["firewall-cmd", "--zone=public", "--remove-masquerade", "--permanent"],
|
|
sudo=True,
|
|
)
|
|
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = False
|
|
_save_config(cfg)
|
|
|
|
_reload()
|
|
full_state = {
|
|
"active_zones": {},
|
|
"interfaces": [],
|
|
"available_services": [],
|
|
"zones": {},
|
|
"rich_rules": {},
|
|
"timestamp": "",
|
|
}
|
|
backup_path = _save_backup(full_state)
|
|
logger.info("Firewall config applied to %d zones", len(applied))
|
|
return {
|
|
"applied_zones": applied,
|
|
"backup": backup_path,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes — GET endpoints read from state, mutations call refresh_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_fw_state() -> dict[str, Any]:
|
|
"""Return firewall state from the state store, or empty dict if absent."""
|
|
fw = _get_state()
|
|
if fw is None:
|
|
return {}
|
|
return fw
|
|
|
|
|
|
@registry.register(GET_FIREWALL_INTERFACES)
|
|
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|
"""GET /firewall/interfaces — return active interfaces from state."""
|
|
fw = _get_fw_state()
|
|
return fw.get("interfaces", [])
|
|
|
|
|
|
@registry.register(GET_FIREWALL_ZONES)
|
|
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
|
"""Return active + available zones from state store.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
Dict with ``active`` (zone→interfaces mapping) and
|
|
``available`` (list of all zone names).
|
|
"""
|
|
fw = _get_fw_state()
|
|
active = fw.get("active_zones", {})
|
|
zones = fw.get("zones", {})
|
|
return {"active": active, "available": list(zones.keys())}
|
|
|
|
|
|
@registry.register(GET_FIREWALL_ZONES_INFO)
|
|
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Return zone config by name; ``NotFoundError`` if absent.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` key (zone name).
|
|
|
|
Returns:
|
|
Zone config dict.
|
|
|
|
Raises:
|
|
ValueError: If ``zone`` key is missing from body.
|
|
NotFoundError: If the specified zone does not exist.
|
|
"""
|
|
if not body or "zone" not in body:
|
|
raise ValueError("'zone' is required")
|
|
zone = body["zone"]
|
|
fw = _get_fw_state()
|
|
zones = fw.get("zones", {})
|
|
if zone not in zones:
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
return zones[zone]
|
|
|
|
|
|
@registry.register(GET_FIREWALL_ZONES_ALL)
|
|
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|
"""Return list of all active zone configs.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
List of zone config dicts for all active zones.
|
|
"""
|
|
fw = _get_fw_state()
|
|
active = fw.get("active_zones", {})
|
|
zones = fw.get("zones", {})
|
|
result: list[dict[str, Any]] = []
|
|
for zone_name in active:
|
|
if zone_name in zones:
|
|
result.append(zones[zone_name])
|
|
return result
|
|
|
|
|
|
@registry.register(GET_FIREWALL_SERVICES)
|
|
def get_services(_request: Any, _body: Any) -> list[str]:
|
|
"""Return list of available firewall services.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
List of service names available in firewalld.
|
|
"""
|
|
fw = _get_fw_state()
|
|
return fw.get("available_services", [])
|
|
|
|
|
|
@registry.register(GET_FIREWALL_CONFIG)
|
|
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|
"""Return declarative config from JSON store.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
Full firewall config dict.
|
|
"""
|
|
return _get_config()
|
|
|
|
|
|
@registry.register(POST_FIREWALL_CONFIG)
|
|
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Save the full declarative firewall configuration.
|
|
|
|
Validates that the request body contains a ``zones`` dict, persists it
|
|
to the JSON store, emits a ``config_saved`` sync event to the
|
|
cross-subsystem sync bus, and refreshes the state store.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with a ``zones`` dict mapping zone names to
|
|
zone configurations.
|
|
|
|
Returns:
|
|
Dict with ``config_saved`` flag set to ``True``.
|
|
|
|
Raises:
|
|
ValueError: If body is empty, missing ``zones`` key,
|
|
or ``zones`` is not a dict.
|
|
"""
|
|
if not body or "zones" not in body:
|
|
raise ValueError("'zones' key is required")
|
|
if not isinstance(body["zones"], dict):
|
|
raise ValueError("'zones' must be a dict")
|
|
_save_config(body)
|
|
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
|
sync_result = bus.emit(
|
|
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"config_saved": True}
|
|
|
|
|
|
@registry.register(PATCH_FIREWALL_CONFIG)
|
|
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Deep-merge body into current config, save, emit sync event, refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with partial config to merge.
|
|
|
|
Returns:
|
|
Dict with ``config_saved`` flag set to ``True``.
|
|
|
|
Raises:
|
|
ValueError: If body is empty.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body must be a JSON object")
|
|
from lib.common import deep_merge
|
|
|
|
current = _get_config()
|
|
merged = deep_merge(current, body)
|
|
_save_config(merged)
|
|
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
|
sync_result = bus.emit(
|
|
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"config_saved": True}
|
|
|
|
|
|
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
|
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
|
"""Return pending changes with human-readable summaries.
|
|
|
|
Compares saved config against live firewalld state and returns a list
|
|
of differences.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
Dict with ``pending`` (list of change dicts) and ``pending_summary``
|
|
(human-readable strings).
|
|
"""
|
|
fw = _get_fw_state()
|
|
pending = fw.get("pending", {})
|
|
|
|
changes = pending.get("pending", [])
|
|
|
|
summaries = [
|
|
fw_change_summary(c.get("zone", "unknown"), c.get("type", "unknown"), c)
|
|
for c in changes
|
|
]
|
|
return {**pending, "pending_summary": summaries}
|
|
|
|
|
|
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
|
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|
"""Apply pending config to live firewalld, emit sync event, refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: Optional JSON body; ``{"force": true}`` overrides the
|
|
management-lockout guard for the default zone.
|
|
|
|
Returns:
|
|
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
|
and ``synced`` (affected subsystems).
|
|
"""
|
|
force = bool(_body and _body.get("force"))
|
|
result = _config_apply(force=force)
|
|
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
|
sync_result = bus.emit(
|
|
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
result["synced"] = sync_result.affected_subsystems
|
|
return result
|
|
|
|
|
|
@registry.register(POST_FIREWALL_ZONES_CREATE)
|
|
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Create new zone via firewall-cmd, emit sync event, refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``name`` (zone name) and optional ``target``.
|
|
|
|
Returns:
|
|
Dict with ``zone`` key set to the zone name.
|
|
|
|
Raises:
|
|
ValueError: If body is empty, missing name, or zone already exists.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone_name = body.get("name", "").strip()
|
|
target = body.get("target", "default").strip() or "default"
|
|
if not zone_name:
|
|
raise ValueError("Zone name is required")
|
|
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
|
if zone_name in available:
|
|
raise ValueError(f"Zone '{zone_name}' already exists")
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--set-target={target}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone_name}
|
|
|
|
|
|
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
|
|
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Delete zone via firewall-cmd, emit sync event, refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` (zone name).
|
|
|
|
Returns:
|
|
Dict with ``zone`` key set to the zone name.
|
|
|
|
Raises:
|
|
ValueError: If body is empty or missing ``zone``.
|
|
NotFoundError: If the zone does not exist.
|
|
"""
|
|
if not body or "zone" not in body:
|
|
raise ValueError("'zone' is required")
|
|
zone = body["zone"]
|
|
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
|
if zone not in available:
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
|
_reload()
|
|
logger.info("Zone '%s' deleted", zone)
|
|
sync_result = bus.emit(
|
|
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone}
|
|
|
|
|
|
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
|
|
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Replace zone interfaces, reassigning interfaces from old zones.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` and ``interfaces`` list.
|
|
|
|
Returns:
|
|
Dict with ``zone`` and ``interfaces`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing ``zone``.
|
|
NotFoundError: If the zone does not exist.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
interfaces = body.get("interfaces", [])
|
|
if not zone:
|
|
raise ValueError("'zone' is required")
|
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
|
|
# Determine old zone for each interface being reassigned
|
|
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
|
active = _parse_active_zones(active_raw)
|
|
|
|
for iface in interfaces:
|
|
# Find which zone currently owns this interface
|
|
old_zone = None
|
|
for az, az_ifaces in active.items():
|
|
if iface in az_ifaces:
|
|
old_zone = az
|
|
break
|
|
# Remove from old zone (if different from target)
|
|
if old_zone and old_zone != zone:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={old_zone}",
|
|
"--remove-interface=" + iface,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
# Add to target zone
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--add-interface=" + iface,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
|
|
_reload()
|
|
|
|
# Update config
|
|
cfg = _get_config()
|
|
cfg.setdefault("zones", {})
|
|
cfg["zones"].setdefault(zone, {})
|
|
cfg["zones"][zone]["interfaces"] = list(interfaces)
|
|
# Remove interface from any old zone in config
|
|
for old_zone_name, old_zone_cfg in cfg["zones"].items():
|
|
if old_zone_name == zone:
|
|
continue
|
|
old_ifaces = old_zone_cfg.get("interfaces", [])
|
|
new_ifaces = [i for i in old_ifaces if i not in interfaces]
|
|
if len(new_ifaces) < len(old_ifaces):
|
|
if new_ifaces:
|
|
old_zone_cfg["interfaces"] = new_ifaces
|
|
elif "interfaces" in old_zone_cfg:
|
|
del old_zone_cfg["interfaces"]
|
|
_save_config(cfg)
|
|
|
|
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "interfaces": interfaces}
|
|
|
|
|
|
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
|
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Replace zone services, persist to declarative config, and refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` and ``services`` list; optional
|
|
``force`` (bool) overrides the management-lockout guard.
|
|
|
|
Returns:
|
|
Dict with ``zone`` and ``services`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing ``zone``.
|
|
NotFoundError: If the specified zone does not exist.
|
|
ConflictError: If the change would strip both https and ssh from the
|
|
firewalld default zone and ``force`` is not set.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
services = body.get("services", [])
|
|
if not zone:
|
|
raise ValueError("'zone' is required")
|
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
if not body.get("force") and _would_remove_mgmt(zone, list(services)):
|
|
raise ConflictError(
|
|
f"Refusing to remove both https and ssh from default zone '{zone}': "
|
|
f"management access and remote recovery would be lost. Add at least "
|
|
f'one of them back, or send "force": true to override.'
|
|
)
|
|
current = _parse_zone_output(
|
|
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
|
).get("services", [])
|
|
for svc in current:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--remove-service={svc}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
check=False,
|
|
)
|
|
for svc in services:
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--add-service={svc}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
|
|
# Keep the declarative config in sync so the next apply does not
|
|
# reconcile the live services back to the stale config value.
|
|
cfg = _get_config()
|
|
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
|
|
_save_config(cfg)
|
|
logger.info("Zone '%s' services set to %s", zone, services)
|
|
sync_result = bus.emit(
|
|
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "services": services}
|
|
|
|
|
|
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
|
|
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Add rich rule with auto-generated ID from uuid4.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` (zone name) and ``rule``
|
|
(rich rule string).
|
|
|
|
Returns:
|
|
Dict with ``zone``, ``id`` (generated UUID), and ``rule`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing ``zone`` or ``rule``.
|
|
NotFoundError: If the specified zone does not exist.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
rule = body.get("rule", "").strip()
|
|
if not zone or not rule:
|
|
raise ValueError("'zone' and 'rule' are required")
|
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--add-rich-rule=" + rule,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
cfg = _get_config()
|
|
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("rich_rules", [])
|
|
rule_id = uuid4().hex[:8]
|
|
entry = {"id": rule_id, "rule": rule}
|
|
cfg["zones"][zone]["rich_rules"].append(entry)
|
|
_save_config(cfg)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "id": rule_id, "rule": rule}
|
|
|
|
|
|
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
|
|
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Remove rich rule by ID, emit sync event, refresh state.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` and ``id`` (rule ID).
|
|
|
|
Returns:
|
|
Dict with ``zone`` and ``id`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing ``zone`` or ``id``.
|
|
NotFoundError: If the zone or rule does not exist.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
rule_id = body.get("id", "").strip()
|
|
if not zone or not rule_id:
|
|
raise ValueError("'zone' and 'id' are required")
|
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
cfg = _get_config()
|
|
zone_cfg = cfg.get("zones", {}).get(zone, {})
|
|
entry = None
|
|
for r in zone_cfg.get("rich_rules", []):
|
|
if r.get("id") == rule_id:
|
|
entry = r
|
|
break
|
|
if entry is None:
|
|
raise NotFoundError(f"Rich rule '{rule_id}' not found in zone '{zone}'")
|
|
rule = entry["rule"]
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--remove-rich-rule=" + rule,
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
zone_cfg["rich_rules"] = [
|
|
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
|
]
|
|
_save_config(cfg)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "id": rule_id}
|
|
|
|
|
|
@registry.register(GET_FIREWALL_RICH_RULES)
|
|
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""Return rich rules list for a zone from state store, matched with config IDs.
|
|
|
|
Rules from the live firewall that match a config entry get their ID
|
|
included; rules without a config entry are returned without an ID.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` key.
|
|
|
|
Returns:
|
|
List of dicts with ``id`` (if available) and ``rule`` keys.
|
|
|
|
Raises:
|
|
ValueError: If ``zone`` key is missing from body.
|
|
"""
|
|
if not body or "zone" not in body:
|
|
raise ValueError("'zone' is required")
|
|
zone = body["zone"]
|
|
fw = _get_fw_state()
|
|
rich_rules = fw.get("rich_rules", {})
|
|
cfg = _get_config()
|
|
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
|
|
result: list[dict[str, Any]] = []
|
|
zone_rules = rich_rules.get(zone, [])
|
|
for rule_str in zone_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 result
|
|
|
|
|
|
@registry.register(POST_FIREWALL_MASQUERADE)
|
|
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Enable/disable masquerade on a zone.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone`` and ``enable`` (boolean).
|
|
|
|
Returns:
|
|
Dict with ``zone`` and ``masquerade`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing ``zone`` or ``enable``, or if
|
|
attempting to enable masquerade on the ``public`` zone.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
enable = body.get("enable")
|
|
if not zone or enable is None:
|
|
raise ValueError("'zone' and 'enable' (bool) are required")
|
|
if zone == "public" and enable:
|
|
raise ValueError(
|
|
"Masquerade (NAT) is not supported on the public zone — enable it on 'internal' or 'vpn' instead"
|
|
)
|
|
action = "--add-masquerade" if enable else "--remove-masquerade"
|
|
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
|
_reload()
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "masquerade": bool(enable)}
|
|
|
|
|
|
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
|
|
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Add port forwarding rule with auto-generated ID.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone``, ``port``, ``proto``, and optionally
|
|
``toaddr`` and ``toport``.
|
|
|
|
Returns:
|
|
Dict with ``zone``, ``id``, ``port``, and ``proto`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing required fields.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
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:
|
|
raise ValueError("'zone', 'port', and 'proto' are required")
|
|
|
|
fwd = f"port={port}/proto={proto}"
|
|
if toaddr and toport:
|
|
fwd += f"/toaddr={toaddr}/toport={toport}"
|
|
elif toport:
|
|
fwd += f"/toport={toport}"
|
|
elif toaddr:
|
|
fwd += f"/toaddr={toaddr}"
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--add-forward-port={fwd}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
fp_id = uuid4().hex[:8]
|
|
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
|
|
if toaddr:
|
|
entry["toaddr"] = toaddr
|
|
if toport:
|
|
entry["toport"] = int(toport)
|
|
cfg = _get_config()
|
|
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
|
cfg["zones"][zone]["forward_ports"].append(entry)
|
|
_save_config(cfg)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
|
|
|
|
|
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
|
|
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Remove port forwarding rule.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
body: JSON body with ``zone``, ``port``, and ``proto``.
|
|
|
|
Returns:
|
|
Dict with ``zone``, ``port``, and ``proto`` keys.
|
|
|
|
Raises:
|
|
ValueError: If body is missing required fields.
|
|
NotFoundError: If the zone or forward port does not exist.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
zone = body.get("zone", "").strip()
|
|
port = body.get("port")
|
|
proto = body.get("proto", "").strip()
|
|
if not zone or port is None or not proto:
|
|
raise ValueError("'zone', 'port', and 'proto' are required")
|
|
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
|
if zone not in available:
|
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
|
fwd = f"port={port}/proto={proto}"
|
|
cfg = _get_config()
|
|
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
|
|
found = False
|
|
for fp in fps:
|
|
if fp.get("port") == port and fp.get("proto") == proto:
|
|
found = True
|
|
if fp.get("toaddr") and fp.get("toport"):
|
|
fwd += f"/toaddr={fp['toaddr']}/toport={fp['toport']}"
|
|
elif fp.get("toport"):
|
|
fwd += f"/toport={fp['toport']}"
|
|
elif fp.get("toaddr"):
|
|
fwd += f"/toaddr={fp['toaddr']}"
|
|
break
|
|
if not found:
|
|
raise NotFoundError(f"Forward port {port}/{proto} not found in zone '{zone}'")
|
|
run(
|
|
[
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--remove-forward-port={fwd}",
|
|
"--permanent",
|
|
],
|
|
sudo=True,
|
|
)
|
|
_reload()
|
|
cfg.setdefault("zones", {}).setdefault(zone, {})
|
|
cfg["zones"][zone]["forward_ports"] = [
|
|
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
|
|
]
|
|
_save_config(cfg)
|
|
sync_result = bus.emit(
|
|
SyncEvent(
|
|
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
|
|
)
|
|
)
|
|
refresh_state(["firewall", *sync_result.affected_subsystems])
|
|
return {"zone": zone, "port": int(port), "proto": proto}
|
|
|
|
|
|
@registry.register(GET_FIREWALL_STATE)
|
|
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
|
"""Return complete firewall state snapshot.
|
|
|
|
Args:
|
|
_request: The incoming HTTP request (unused).
|
|
_body: The request body (unused).
|
|
|
|
Returns:
|
|
Dict with ``active_zones``, ``interfaces``, ``available_services``,
|
|
``zones``, ``rich_rules``, and ``timestamp`` keys. Returns empty
|
|
dict if state is not populated yet.
|
|
"""
|
|
fw = _get_state()
|
|
if fw is None:
|
|
return {}
|
|
return {
|
|
"active_zones": fw.get("active_zones", {}),
|
|
"interfaces": fw.get("interfaces", []),
|
|
"available_services": fw.get("available_services", []),
|
|
"zones": fw.get("zones", {}),
|
|
"rich_rules": fw.get("rich_rules", {}),
|
|
"timestamp": fw.get("timestamp", ""),
|
|
}
|