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.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+26
View File
@@ -0,0 +1,26 @@
"""State collectors for vacuum-walld.
Importing this package registers every collector with the ``lib.state``
store (registration side effect). Import it before the first
``populate()``/``poll()`` call.
"""
from daemon.collectors import (
acme,
dnsmasq,
firewall,
networkd,
nginx,
system,
wireguard,
)
__all__ = [
"acme",
"dnsmasq",
"firewall",
"networkd",
"nginx",
"system",
"wireguard",
]
+165
View File
@@ -0,0 +1,165 @@
"""ACME state collector."""
import logging
import os
from pathlib import Path
from typing import Any
from lib import schema
from lib.common import load_json
from lib.state import PROJECT_DIR, _now_iso, register_collector
logger = logging.getLogger(__name__)
_CA_NAME_MAP: dict[str, str] = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
def _resolve_ca_name(ca_server: str) -> str:
"""Map a CA server identifier to its human-readable name.
Uses prefix matching sorted by longest prefix first to avoid
shorter prefixes winning (e.g. "letsencrypt" matching before
"letsencrypt.org").
Args:
ca_server: Raw CA server string from acme.sh config.
Returns:
Human-readable name, or unchanged string if no match.
"""
for prefix, name in sorted(
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
):
if ca_server.startswith(prefix):
return name
return ca_server
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh account information and return account status dict.
Checks three sources in order:
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
2. Declarative ``config/acme/config.json`` (saved by the registration
handler with ``email`` and ``ca`` fields)
Args:
acme_home: Optional override for ACME home directory. Falls back
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
Returns:
Dict with ``registered``, ``email``, ``ca``, and
``key_length`` keys. If no account is found, ``registered`` is
``False`` with empty / ``None`` values.
"""
if acme_home is None:
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
default = {
"registered": False,
"email": "",
"ca": "",
"key_length": None,
}
# 1. Legacy .account.conf (acme.sh v2.x)
account_path = acme_home / ".account.conf"
if account_path.is_file():
try:
text = account_path.read_text()
except OSError:
pass
else:
email = ""
ca_raw = ""
key_length = None
for line in text.splitlines():
if line.startswith("ACME_LEEMAIL="):
email = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_MCA="):
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
elif line.startswith("ACME_CERTKEYSIZE="):
raw_val = line.split("=", 1)[1].strip().strip("'\"")
key_length = int(raw_val) if raw_val.isdigit() else None
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": key_length,
}
# 2. Declarative config (saved by register_account / set_email handlers)
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
# (ca/<server>/account.json) — we can't reliably parse those without
# walking the directory, so fall back to the declarative config
# which the handlers keep in sync.
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
project_root = acme_home.parent.parent # data/acme → data → project root
acme_cfg = project_root / "config" / "acme" / "config.json"
data = load_json(acme_cfg)
email = (data.get("email") or "").strip()
ca_raw = (data.get("ca") or "").strip()
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": None,
}
except (OSError, ValueError):
pass
return default
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
from lib.acme import _read_acme_email
return _read_acme_email()
def _collect_acme() -> schema.AcmeState:
"""Collect ACME certificate list and email.
Returns:
Dict containing certificate details and registered email.
"""
email = _get_acme_email()
# Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an
# ownership flip) must not blank the whole dashboard via a cleared
# state store. Collect what we can and surface the failure in
# `status.error` so the poll diff still detects recovery.
cert_error: str | None = None
try:
from lib.acme import list_certs
certs = list_certs()
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)
certs = []
cert_error = str(exc)
account = _parse_account_conf()
return {
"certs": certs,
"email": email,
"account": account,
"status": {"error": cert_error},
"timestamp": _now_iso(),
}
register_collector("acme", _collect_acme)
+85
View File
@@ -0,0 +1,85 @@
"""DNSMasq state collector."""
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.dnsmasq import DEFAULT_CFG, get_config
from lib.state import _now_iso, register_collector
def _collect_dnsmasq() -> schema.DnsmasqState:
"""Collect dnsmasq status, config, and leases.
Returns:
Dict containing config, service status, leases, and timestamp.
"""
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
# Load config (lib defaults; fall back to them when the file is broken)
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CFG)
# Service status
service_active = False
try:
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
service_active = proc.stdout.strip() == "active"
except Exception:
pass
# Leases
leases: list[dict[str, Any]] = []
try:
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
leases.append(
{
"expires": ts.isoformat() if ts else "",
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
)
except Exception:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
pending_changes, pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
}
register_collector("dnsmasq", _collect_dnsmasq)
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
+175
View File
@@ -0,0 +1,175 @@
"""Firewall state collector (read-only sudo queries)."""
import contextlib
from typing import Any
from lib import schema
from lib.common import load_json, run, strip_apply_meta
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
get_service_descriptions,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import get_config as _network_get_config
from lib.state import (
PROJECT_DIR,
_now_iso,
register_collector,
register_volatile,
)
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a port-forward dict to a compact string representation.
Args:
fp: Port-forward entry containing port and proto keys.
Returns:
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
"""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
return "/".join(parts)
def _collect_firewall() -> schema.FirewallState:
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
iface_map: dict[str, dict[str, Any]] = {}
for line in link_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
iface_state = parts[i + 1]
if p == "mtu" and i + 1 < len(parts):
mtu = int(parts[i + 1])
if p.startswith("link/ether") and i + 1 < len(parts):
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"mac": mac,
"state": iface_state,
"mtu": mtu,
"ips": [],
"ipv6": [],
"zone": None,
}
for line in addr_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1].split("@")[0]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
for entry in iface_map.values():
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
ifaces = list(iface_map.values())
# Collect all zones in a single call (replaces per-zone loop)
zones: dict[str, dict[str, Any]] = {}
try:
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
zones = _parse_all_zones_output(all_zones_raw)
except Exception:
pass
# Load config (strip apply bookkeeping keys, as the other collectors do)
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = strip_apply_meta(load_json(fw_config_path))
# Pending changes
full_state = {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"timestamp": _now_iso(),
}
pending = {}
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
net_cfg: dict[str, Any] = {}
with contextlib.suppress(Exception):
net_cfg = _network_get_config()
covered: set[str] = set()
for zone_ifaces in active.values():
covered.update(zone_ifaces)
for zone in zones.values():
covered.update(zone.get("interfaces", []))
uncovered_interfaces = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg") and name not in covered
]
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
# Parsed from the firewalld service XML definitions; cached per
# process so the 30s poll does not re-read the files.
"service_descriptions": get_service_descriptions(),
"uncovered_interfaces": uncovered_interfaces,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
"pending": pending,
"timestamp": _now_iso(),
}
register_collector("firewall", _collect_firewall)
register_volatile(
"firewall",
frozenset(
{
"interfaces[].ips",
"interfaces[].ipv6",
}
),
)
+67
View File
@@ -0,0 +1,67 @@
"""Networkd state collector."""
from typing import Any
from lib import schema
from lib.common import compute_pending, run, strip_apply_meta
from lib.network import get_config, parse_networkctl_status
from lib.state import _now_iso, register_collector, register_volatile
def _collect_networkd() -> schema.NetworkdState:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output,
config, and pending changes status.
"""
# Load config
try:
net_cfg = get_config()
except Exception:
net_cfg = {}
pending_changes, net_pending_diff = compute_pending(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {
"pending_changes": pending_changes,
"pending_diff": net_pending_diff,
}
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
+66
View File
@@ -0,0 +1,66 @@
"""Nginx state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, strip_apply_meta
from lib.nginx import DEFAULT_CONFIG, SITES_DIR, _resolve_paths, get_config
from lib.state import _now_iso, register_collector
def _collect_nginx() -> schema.NginxState:
"""Collect nginx config and domains list.
Returns:
Dict containing config, domains, and timestamp.
"""
# Load config via lib.nginx — a pure read that applies the legacy-format
# migration in memory (the one-shot on-disk migration runs at daemon
# startup, see lib.bootstrap).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
# Build flattened domains list (one entry per path)
backends = cfg.get("backends", {})
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = _resolve_paths(dom, backends)
if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
"path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
"cert": dom.get("cert"),
}
if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
domains.append(entry)
pending_changes, nginx_pending_diff = compute_pending(cfg)
safe_cfg = strip_apply_meta(cfg)
return {
"config": safe_cfg,
"domains": domains,
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
register_collector("nginx", _collect_nginx)
+125
View File
@@ -0,0 +1,125 @@
"""System metrics collector."""
from pathlib import Path
from typing import Any
from lib import schema
from lib.state import _now_iso, register_collector, register_volatile
def _parse_meminfo() -> dict[str, Any]:
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
info: dict[str, int] = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
parts = value.strip().split()
val = int(parts[0])
# Convert kB to bytes
if parts and parts[-1] == "kB":
val *= 1024
info[key] = val
except (OSError, ValueError):
return {}
return info
def _collect_system() -> schema.SystemState:
"""Collect system-wide metrics: CPU load, memory, network traffic.
Reads from /proc and /sys — no subprocess needed.
Returns:
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
"""
# CPU load
loads = []
try:
parts = Path("/proc/loadavg").read_text().split()
loads = [float(x) for x in parts[:3]]
except (OSError, ValueError):
loads = [0.0, 0.0, 0.0]
# Memory
meminfo_raw = _parse_meminfo()
mem_total = meminfo_raw.get("MemTotal", 0)
mem_free = meminfo_raw.get("MemFree", 0)
mem_available = meminfo_raw.get("MemAvailable", mem_free)
mem_buffers = meminfo_raw.get("Buffers", 0)
mem_cached = meminfo_raw.get("Cached", 0)
mem_used = mem_total - mem_free - mem_buffers - mem_cached
if mem_used < 0:
mem_used = mem_total - mem_available
# Swap
swap_total = meminfo_raw.get("SwapTotal", 0)
swap_free = meminfo_raw.get("SwapFree", 0)
swap_used = swap_total - swap_free
# Network traffic from /sys/class/net/<iface>/statistics/
traffic: dict[str, dict[str, int]] = {}
try:
net_root = Path("/sys/class/net")
if net_root.is_dir():
for iface_dir in net_root.iterdir():
stats_dir = iface_dir / "statistics"
if not stats_dir.is_dir():
continue
iface_name = iface_dir.name
rx_bytes = 0
tx_bytes = 0
rx_packets = 0
tx_packets = 0
try:
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
except (OSError, ValueError):
continue
traffic[iface_name] = {
"rx_bytes": rx_bytes,
"tx_bytes": tx_bytes,
"rx_packets": rx_packets,
"tx_packets": tx_packets,
}
except OSError:
pass
return {
"load": {
"load1": loads[0],
"load5": loads[1],
"load15": loads[2],
},
"memory": {
"total": mem_total,
"available": mem_available,
"used": mem_used,
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
},
"swap": {
"total": swap_total,
"used": swap_used,
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
},
"traffic": traffic,
"timestamp": _now_iso(),
}
register_collector("system", _collect_system)
register_volatile(
"system",
frozenset(
{
"load",
"memory",
"swap",
"traffic",
}
),
)
+125
View File
@@ -0,0 +1,125 @@
"""WireGuard state collector."""
from copy import deepcopy
from typing import Any
from lib import schema
from lib.common import compute_pending, run_proc, strip_apply_meta
from lib.state import _now_iso, register_collector, register_volatile
from lib.wireguard import DEFAULT_CONFIG, get_config, parse_wg_show_output
def _collect_wireguard() -> schema.WgState:
"""Collect WireGuard config, per-class status, and peers.
Returns:
Dict containing interface config, per-class runtime status,
combined peers, and overall tunnel status.
"""
# Load config via lib.wireguard defaults (which include the built-in
# full/internet access classes).
try:
cfg = get_config()
except Exception:
cfg = deepcopy(DEFAULT_CONFIG)
pending_changes, pending_diff = compute_pending(cfg)
# Safe config (strip private keys from interface and access classes)
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
safe["access_classes"] = {}
for ck, cv in cfg.get("access_classes", {}).items():
if isinstance(cv, dict):
entry = dict(cv)
entry.pop("private_key", None)
safe["access_classes"][ck] = entry
# Peers list (safe)
peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
peers.append(entry)
# Runtime status — per-class interfaces
status: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
classes = cfg.get("access_classes", {})
any_up = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not isinstance(class_cfg, dict):
continue
ifname = f"wg-{class_key}"
try:
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode != 0:
status["classes"][class_key] = {
"up": False,
"interface": {},
"peers": [],
}
continue
parsed = parse_wg_show_output(res.stdout.strip())
status["classes"][class_key] = {
"up": parsed["up"],
"interface": parsed.get("interface", {}),
"peers": parsed.get("peers", []),
}
if parsed["up"]:
any_up = True
except Exception:
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
# Also collect legacy single-interface status
try:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = parse_wg_show_output(res.stdout.strip())
status["up"] = True
status["interface"] = parsed.get("interface", {})
status["peers"] = parsed.get("peers", [])
any_up = True
except Exception:
pass
if any_up:
status["up"] = True
status["pending_changes"] = pending_changes
# Drop any private-key paths so the pending summary never exposes
# key material.
status["pending_diff"] = [d for d in pending_diff if "private_key" not in d["path"]]
return {
"config": safe,
"status": status,
"peers": peers,
"timestamp": _now_iso(),
}
register_collector("wireguard", _collect_wireguard)
register_volatile(
"wireguard",
frozenset(
{
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
"status.classes[].peers[].transfer_received",
"status.classes[].peers[].transfer_sent",
"status.classes[].peers[].latest_handshake",
}
),
)