25a1943fce
- Replace per-zone --list-all calls with single --list-all-zones in _collect_firewall - Add _parse_all_zones_output() parser with rich rules/rich-rules normalization - Convert daemon shutdown to async with proper runner cleanup and socket unlink - Add TimeoutStopSec=15 to vacuum-walld.service for graceful stop - Fix exception handling in _collect_dnsmasq - Remove management badge from proxy path rows
406 lines
12 KiB
Python
406 lines
12 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`` or a zone block
|
|
from ``--list-all-zones`` 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()
|
|
|
|
# --list-all-zones uses "rich rules" (space) while
|
|
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
|
|
if key == "rich rules":
|
|
key = "rich-rules"
|
|
|
|
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
|
|
|
|
|
|
def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
|
|
"""Parse the combined ``firewall-cmd --list-all-zones`` output.
|
|
|
|
Returns a dict mapping each zone name to its parsed info dict
|
|
(same structure as ``_parse_zone_output``).
|
|
"""
|
|
zones: dict[str, dict[str, Any]] = {}
|
|
current_name: str | None = None
|
|
current_lines: list[str] = []
|
|
|
|
for raw_line in output.splitlines():
|
|
if not raw_line.strip():
|
|
continue
|
|
# Non-indented line starts a new zone block
|
|
if raw_line[0].isspace():
|
|
if current_name is not None:
|
|
current_lines.append(raw_line.strip())
|
|
else:
|
|
# Finalize previous zone
|
|
if current_name is not None and current_lines:
|
|
zones[current_name] = _parse_zone_output(
|
|
current_name, "\n".join(current_lines)
|
|
)
|
|
# Extract zone name (discard trailing parenthetical metadata)
|
|
name = raw_line.strip().split()[0]
|
|
if "(" in name:
|
|
name = name[: name.index("(")]
|
|
current_name = name
|
|
current_lines = []
|
|
|
|
# Finalize last zone
|
|
if current_name is not None and current_lines:
|
|
zones[current_name] = _parse_zone_output(
|
|
current_name, "\n".join(current_lines)
|
|
)
|
|
|
|
return zones
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_all_zones_output",
|
|
"_parse_forward_ports",
|
|
"_parse_interfaces",
|
|
"_parse_zone_output",
|
|
"config_pending",
|
|
"get_config",
|
|
"load_backup",
|
|
"save_backup",
|
|
"save_config",
|
|
]
|