faa076370d
- 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.
610 lines
21 KiB
Python
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",
|
|
]
|