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:
+204
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user