Files
vacuum-wall/daemon/handlers/firewall.py
T
mteehan 75b86fd60d fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
  broke the two-user model: a tree left owner-only by one user made
  every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
  files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
  is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
  transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
  validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
  status.error (AcmeState.status) so the certs page can surface it

firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport

status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm

system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes

nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)

install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership

daemon/server: loop.get_exception_handler() (aiohttp API fix)

tests: 888 pytest + 24 node passing; ruff clean
2026-09-01 02:35:04 +00:00

1294 lines
45 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 import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
_normalize_target,
_now_iso,
_parse_active_zones,
_parse_all_zones_output,
_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 in two cases; pass ``force=True`` to override either:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so
their current live interfaces count as coverage, as do the live
interfaces of zones that are live but absent from the config.
"""
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}}.'
)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
# the declarative config, captured before any mutation. The permanent
# view is what is reproducible for manual recovery.
backup_path = _save_backup(
{
"timestamp": _now_iso(),
"default_zone": _default_zone(),
"zones": _parse_all_zones_output(
run(["firewall-cmd", "--list-all-zones", "--permanent"], sudo=True)
),
"config": cfg,
}
)
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.
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
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()
# Record the applied config snapshot + hash so pending-changes detection
# and cancel/revert work like the hash-based subsystems.
applied_cfg = _get_config()
stamp_applied(applied_cfg)
_save_config(applied_cfg)
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 (apply bookkeeping keys stripped).
"""
return strip_apply_meta(_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 and the interface-coverage guard.
Returns:
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
and ``synced`` (affected subsystems).
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
"""
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 a new firewall zone, emit sync event, refresh state.
Runs ``--new-zone`` first (required before ``--set-target``), then sets
the target only when it normalizes to something other than ``default``
(the implicit firewalld target is never re-set), then reloads.
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")
# Create the zone first; --set-target requires the zone to exist.
run(["firewall-cmd", f"--new-zone={zone_name}", "--permanent"], sudo=True)
# "default" is firewalld's implicit target and cannot be meaningfully
# re-set, so only explicit ACCEPT/DROP/REJECT targets are applied.
normalized_target = _normalize_target(target)
if normalized_target != "default":
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={normalized_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.
When the new selection leaves an interface in no zone at all, a
prominent warning is logged (clients on that segment lose connectivity
and DHCP); the operation is not blocked since it is a deliberate UI
action.
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,
)
# Flag interfaces that ended up in no zone at all — clients on those
# segments lose connectivity (including DHCP).
for iface in set(active.get(zone, [])) - set(interfaces):
if not any(
iface in az_ifaces for az, az_ifaces in active.items() if az != zone
):
logger.warning(
"Interface '%s' is now in NO firewall zone: clients on that "
"segment will lose connectivity and DHCP (zone '%s' no longer "
"covers it).",
iface,
zone,
)
_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"]
# This mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
stamp_applied(cfg)
_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. The
# mutation already applied to live firewalld, so re-stamp the applied
# baseline: cancel-all must revert to this state, not an older snapshot.
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
stamp_applied(cfg)
_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)
stamp_applied(cfg)
_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
]
stamp_applied(cfg)
_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.
Also syncs the declarative config (and re-stamps the applied baseline)
when the zone exists in the config, so the pending diff and cancel-all
stay consistent with the live 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()
# Keep the declarative config in sync with the live zone so the pending
# diff and the cancel-all baseline stay consistent. Only touch zones that
# already exist in the config — creating a bare zone entry would
# manufacture spurious service/interface diffs on the next poll.
cfg = _get_config()
zone_cfg = cfg.get("zones", {}).get(zone)
if isinstance(zone_cfg, dict):
zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg)
_save_config(cfg)
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 and toport:
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)
stamp_applied(cfg)
_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)
]
stamp_applied(cfg)
_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", ""),
}