Add declarative firewall config with save-then-apply workflow

New two-step config flow: POST /config saves desired state to
config/firewall/config.json, GET /config/pending diffs against live
firewalld state, POST /config/apply synchronizes live state.  Adds target
normalization helpers and full test coverage for config CRUD and pending
diff logic.
This commit is contained in:
2026-05-14 03:31:49 +00:00
parent 32757e2f40
commit 6106c1434d
4 changed files with 564 additions and 5 deletions
+204 -1
View File
@@ -11,11 +11,18 @@ Flask UI can inspect or restore previous configurations.
import json
import os
import subprocess
from contextlib import suppress
from datetime import UTC
from pathlib import Path
from typing import Any
DATA_DIR: str = "/home/wall/vacuum-wall/data/firewall"
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall")
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
@@ -44,6 +51,7 @@ def _reload() -> None:
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
@@ -651,8 +659,199 @@ def restore_backup(state: dict[str, Any]) -> None:
_reload()
# ---------------------------------------------------------------------------
# Declarative config management (config/firewall/config.json)
# ---------------------------------------------------------------------------
def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
with open(CONFIG_FILE, "w") as fh:
json.dump(DEFAULT_CONFIG, fh, indent=2)
fh.write("\n")
def config_get() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
with open(CONFIG_FILE) as fh:
return json.load(fh)
def config_set(cfg: dict[str, Any]) -> None:
"""Write *cfg* to ``config/firewall/config.json`` (atomic replace)."""
_ensure_config_file()
tmp = CONFIG_FILE.with_name(CONFIG_FILE.name + ".tmp")
with open(tmp, "w") as fh:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
def _normalize_target(target: str) -> str:
"""Map between config JSON target names and firewalld target values."""
if target == "ACCEPT":
return "ACCEPT"
if target == "DROP":
return "DROP"
if target == "REJECT":
return "REJECT"
return "default"
def _live_target_to_config(target: str) -> str:
"""Map firewalld target value back to config JSON canonical form."""
if target == "ACCEPT":
return "ACCEPT"
if target == "DROP":
return "DROP"
if target == "REJECT":
return "REJECT"
return "DEFAULT"
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff.
Returns a dict with ``pending`` (list of change dicts), ``needs_apply``
(bool), and ``live_zones`` (dict of zones not yet in config).
"""
cfg = config_get()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
changes: list[dict[str, Any]] = []
unknown_live: dict[str, Any] = {}
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if not zone_cfg.get("interfaces"):
continue
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.get("services", []))
if cfg_services != live_services:
changes.append(
{
"zone": zone_name,
"type": "services",
"config": sorted(cfg_services),
"live": sorted(live_services),
}
)
cfg_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
for zone_name in live_zones:
if zone_name not in cfg_zones:
unknown_live[zone_name] = {
"interfaces": live_zones[zone_name].get("interfaces", []),
}
return {
"pending": changes,
"needs_apply": len(changes) > 0,
"unmanaged_zones": unknown_live,
}
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld.
Takes a snapshot via ``save_backup()`` first, then reconciles each zone
in the config (create/update, interfaces, services, masquerade), reloads,
and takes another snapshot.
Returns a dict with ``applied_zones`` and a ``backup`` path.
"""
cfg = config_get()
cfg_zones = cfg.get("zones", {})
save_backup()
available = get_available_zones()
applied: list[str] = []
for zone_name, zone_cfg in cfg_zones.items():
need_create = zone_name not in available
if need_create:
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
create_zone(zone_name, target)
else:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default":
with suppress(RuntimeError):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
check=False,
)
set_zone_services(zone_name, zone_cfg.get("services", []))
set_zone_interfaces(zone_name, zone_cfg.get("interfaces", []))
mq = zone_cfg.get("masquerade", False)
if mq is not None:
set_masquerade(zone_name, mq)
applied.append(zone_name)
_reload()
backup_path = save_backup()
return {
"applied_zones": applied,
"backup": backup_path,
}
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"RULES_FILE",
"_reload",
"_run",
@@ -660,6 +859,10 @@ __all__ = [
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"config_apply",
"config_get",
"config_pending",
"config_set",
"create_zone",
"delete_zone",
"get_active_zones",