Files
vacuum-wall/lib/firewall.py
T
mteehan 200e078bc5 refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
2026-05-27 23:39:33 +00:00

359 lines
11 KiB
Python

"""
firewall.py - firewalld parsing helpers & declarative config for Vacuum Wall.
Pure logic only — no subprocess or sudo calls.
All privileged commands are handled by daemon/handlers/firewall.py.
"""
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from lib.common import load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: Path = PROJECT_DIR / "data" / "firewall"
RULES_FILE: Path = DATA_DIR / "rules.json"
CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _parse_active_zones(output: str) -> dict[str, list[str]]:
"""Parse ``firewall-cmd --get-active-zones`` output."""
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
stripped = raw_line.strip()
if not stripped:
continue
if raw_line.startswith(" "):
current_ifaces = (
zones[current_zone]
if current_zone
else zones.get(list(zones.keys())[-1], [])
)
for piece in stripped.split():
if piece.endswith(":"):
continue
if current_zone and piece not in current_ifaces:
current_ifaces.append(piece)
else:
current_zone = stripped.removesuffix(" (default)")
zones[current_zone] = []
return zones
def _parse_interfaces(output: str) -> list[str]:
"""Parse ``ip -o link show`` output."""
ifaces: list[str] = []
for line in output.splitlines():
if line:
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
ifaces.append(name)
return ifaces
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` output."""
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if not value:
if key in ("masquerade", "ics"):
info[key] = False
else:
info[key] = []
else:
if key in (
"interfaces",
"sources",
"services",
"ports",
"protocols",
"icmp-blocks",
"module",
):
info[key] = value.split()
elif key == "forward-ports":
info[key] = _parse_forward_ports(value)
elif key in ("masquerade", "ics"):
info[key] = value.lower() == "yes"
elif key == "rich-rules":
info[key] = [value] if value else []
else:
info[key] = value
info.setdefault("rich-rules", [])
info.setdefault("interfaces", [])
info.setdefault("sources", [])
info.setdefault("services", [])
info.setdefault("ports", [])
info.setdefault("protocols", [])
info.setdefault("forward-ports", [])
info.setdefault("masquerade", False)
info.setdefault("ics", False)
info.setdefault("icmp-blocks", [])
info.setdefault("module", [])
info.setdefault("target", "default")
return info
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
def _parse_forward_port(raw: str) -> dict[str, Any]:
"""Parse a single forward-port specifier into a structured dict."""
result: dict[str, Any] = {}
for piece in raw.split("/"):
if "=" not in piece:
continue
key, _, val = piece.partition("=")
if key == "port":
result["port"] = int(val)
elif key == "proto":
result["proto"] = val
elif key == "toaddr":
result["toaddr"] = val
elif key == "toport":
result["toport"] = int(val)
return result
def _parse_forward_ports(value: str) -> list[dict[str, Any]]:
"""Parse the 'forward-ports' line into a list of structured dicts."""
if not value:
return []
return [_parse_forward_port(raw) for raw in value.split()]
# ---------------------------------------------------------------------------
# State snapshot / backup helpers
# ---------------------------------------------------------------------------
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
return datetime.now(UTC).isoformat()
def save_backup(state: dict[str, Any]) -> str:
"""Write *state* to RULES_FILE on disk."""
save_json(RULES_FILE, state)
logger.info("Firewall state backup saved to %s", RULES_FILE)
return str(RULES_FILE)
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict."""
return load_json(RULES_FILE)
# ---------------------------------------------------------------------------
# Declarative config management (config/firewall/config.json)
# ---------------------------------------------------------------------------
def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist."""
if not CONFIG_FILE.exists():
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
return load_json(CONFIG_FILE)
def save_config(cfg: dict[str, Any]) -> None:
"""Write *cfg* to ``config/firewall/config.json`` (atomic replace)."""
_ensure_config_file()
save_json(CONFIG_FILE, cfg, indent=2)
logger.info("Firewall declarative config saved")
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 _compute_pending_changes(
cfg: dict[str, Any],
live_zones: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Compare declarative config against live zone state, return diff.
Pure function — no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
"""
cfg_zones = cfg.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,
}
)
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
live_rules = set(live_zone.get("rich-rules", []))
if cfg_rules != live_rules:
changes.append(
{
"zone": zone_name,
"type": "rich_rules",
"config_count": len(cfg_rules),
"live_count": len(live_rules),
}
)
cfg_fps = {
(fp.get("port"), fp.get("proto"))
for fp in zone_cfg.get("forward_ports", [])
}
live_fps = {
(fp.get("port"), fp.get("proto"))
for fp in live_zone.get("forward-ports", [])
}
if cfg_fps != live_fps:
changes.append(
{
"zone": zone_name,
"type": "forward_ports",
"config_count": len(cfg_fps),
"live_count": len(live_fps),
}
)
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_pending(state: dict[str, Any]) -> dict[str, Any]:
"""Compare declarative config against firewalld live state, return diff.
*state* is required — the daemon always passes live state via
`daemon.handlers.firewall.get_state()`.
"""
cfg = get_config()
live_zones = state.get("zones", {})
return _compute_pending_changes(cfg, live_zones)
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"RULES_FILE",
"_compute_pending_changes",
"_ensure_config_file",
"_live_target_to_config",
"_normalize_target",
"_now_iso",
"_parse_active_zones",
"_parse_forward_ports",
"_parse_interfaces",
"_parse_zone_output",
"config_pending",
"get_config",
"load_backup",
"save_backup",
"save_config",
]