Files
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

610 lines
21 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 collections.abc import Sequence
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
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": {}}
# Zones firewalld ships by default. They are always present live and are
# never meaningful to flag as "unmanaged (not in config)".
FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset(
{
"block",
"dmz",
"drop",
"external",
"home",
"host",
"internal",
"public",
"trusted",
}
)
# ---------------------------------------------------------------------------
# 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.
firewalld emits each rich rule on its own tab-indented continuation
line after an (empty) ``rich rules:`` entry; those lines carry no
colon and are collected into the ``rich-rules`` list.
"""
info: dict[str, Any] = {"name": zone}
last_key = ""
for line in output.splitlines():
line = line.strip()
if not line:
continue
if ":" not in line:
# Continuation line (rich rules); ignore anything else.
if last_key == "rich-rules":
info.setdefault("rich-rules", []).append(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"
last_key = key
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
# ---------------------------------------------------------------------------
# Service catalog descriptions (firewalld service XML definitions)
# ---------------------------------------------------------------------------
# Built-ins first, /etc second, so user service definitions under
# /etc/firewalld/services override built-ins with the same name.
_SERVICE_XML_DIRS: tuple[Path, ...] = (
Path("/usr/lib/firewalld/services"),
Path("/etc/firewalld/services"),
)
_service_descriptions_cache: dict[str, str] | None = None
def _parse_service_xml(path: Path) -> str:
"""Extract the one-line text from a firewalld service XML definition.
Args:
path: Path to a ``<service>`` XML file.
Returns:
The ``<short>`` text, or ``<description>`` when ``<short>`` is
absent; empty string when neither is present or the file cannot be
read or parsed.
"""
try:
root = ElementTree.parse(path).getroot()
except (OSError, ElementTree.ParseError):
logger.warning("Could not read service definition %s", path, exc_info=True)
return ""
text = root.findtext("short") or root.findtext("description") or ""
return text.strip()
def get_service_descriptions(
dirs: Sequence[Path | str] | None = None,
) -> dict[str, str]:
"""Return a mapping of firewalld service names to one-line descriptions.
Parses the ``*.xml`` service definitions found in *dirs*. When *dirs* is
``None`` the standard system locations are used (see
``_SERVICE_XML_DIRS``) and the result is cached for the process lifetime.
When *dirs* is given the result is computed fresh and nothing is cached.
Unreadable or malformed files are skipped.
Args:
dirs: Directories containing service XML files. ``None`` selects the
default system locations.
Returns:
Dict mapping each service name (file stem) to its description text.
"""
global _service_descriptions_cache
if dirs is None and _service_descriptions_cache is not None:
return dict(_service_descriptions_cache)
search_dirs = [Path(d) for d in dirs] if dirs is not None else _SERVICE_XML_DIRS
descriptions: dict[str, str] = {}
for directory in search_dirs:
try:
entries = sorted(directory.glob("*.xml")) if directory.is_dir() else []
except OSError:
logger.warning("Skipping unreadable service directory %s", directory)
continue
for path in entries:
text = _parse_service_xml(path)
if text:
descriptions[path.stem] = text
if dirs is None:
_service_descriptions_cache = descriptions
return descriptions
# ---------------------------------------------------------------------------
# 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``.
Pure read — never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config`` (or
by the system-config import on first start).
"""
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
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).
The config is the source of truth for zone interfaces: an absent
``interfaces`` key counts as an empty list, so every config zone is
diffed on interfaces. Likewise the target diff is only reported when the
config carries an explicit target that normalizes to something other
than ``default`` — an absent key or a ``default``-normalizing value is
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
forward ports are reported for all config zones.
"""
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, {})
# The config is the source of truth for zone interfaces: an absent
# key counts as an empty list, so every config zone is diffed.
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),
}
)
# Target is unmanaged when the config omits the key or the value
# normalizes to "default" (firewalld's implicit target, which apply
# never re-sets). Only an explicit ACCEPT/DROP/REJECT is diffed.
if "target" in zone_cfg and _normalize_target(zone_cfg["target"]) != "default":
cfg_target = _normalize_target(zone_cfg["target"])
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,
}
)
# public zone masquerade is not reconciled by apply (it is driven by
# the nftables propagation step in daemon/handlers/firewall.py), so
# reporting it as pending here would advertise a change that never
# happens. Skip it to keep the diff consistent with apply.
if zone_name != "public":
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 and zone_name not in FIREWALLD_BUILTIN_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 validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]:
"""Return network-managed interfaces with no firewall zone coverage.
Pure — compares the declarative firewall config against the network
config; no live state. A managed interface is covered when it appears in
some zone's ``interfaces`` list (an absent key counts as empty), or is
explicitly declared in the top-level ``unmanaged`` list. ``lo`` and
``wg*`` interfaces are never guarded (VPN zones are managed by the
WireGuard sync; loopback is normally zoneless).
Args:
fw_cfg: Firewall declarative config (``zones`` plus optional
top-level ``unmanaged`` list).
net_cfg: Network config (``interfaces`` mapping).
Returns:
Sorted list of uncovered interface names; empty when the config is
valid.
"""
managed = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
if not managed:
return []
covered: set[str] = set()
for zone_cfg in fw_cfg.get("zones", {}).values():
if isinstance(zone_cfg, dict):
covered.update(
i for i in zone_cfg.get("interfaces", []) if isinstance(i, str)
)
unmanaged_raw = fw_cfg.get("unmanaged", [])
unmanaged = (
{i for i in unmanaged_raw if isinstance(i, str)}
if isinstance(unmanaged_raw, list)
else set()
)
return sorted(
name for name in managed if name not in covered and name not in unmanaged
)
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)
def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str:
"""Build a human-readable summary string for a firewall change."""
if ctype == "interfaces":
config_if = change.get("config", [])
live_if = change.get("live", [])
return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})"
if ctype == "services":
config_sv = change.get("config", [])
live_sv = change.get("live", [])
return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})"
if ctype == "rich_rules":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return (
f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})"
)
if ctype == "forward_ports":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})"
if ctype == "masquerade":
cfg_val = change.get("config", False)
live_val = change.get("live", False)
return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})"
if ctype == "target":
cfg_val = change.get("config", "default")
live_val = change.get("live", "default")
return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})"
return f"Zone {zone}: {ctype} changed"
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"FIREWALLD_BUILTIN_ZONES",
"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",
"fw_change_summary",
"get_config",
"get_service_descriptions",
"load_backup",
"save_backup",
"save_config",
"validate_coverage",
]