Files
vacuum-wall/lib/state.py
T
mteehan 5ba0f31767 Add state management, WebSocket polling, html.js templating, and refactor pages
- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
2026-06-23 21:12:56 +00:00

1119 lines
34 KiB
Python

"""Pre-computed state store for vacuum-walld.
Collects system state at startup and on demand. Handlers read from the
state instead of invoking subprocesses on every request.
"""
import contextlib
import logging
import os
import shutil
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
from lib.common import load_json, run, run_proc
from lib.firewall import (
_parse_active_zones,
_parse_zone_output,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
"firewall": 30,
"wireguard": 10,
"dnsmasq": 10,
"networkd": 10,
}
# ---------------------------------------------------------------------------
# State store
# ---------------------------------------------------------------------------
class State:
"""In-memory state store keyed by subsystem name.
Each subsystem's value is a dict collected from the corresponding
``collect_*`` function. A value of ``None`` means the subsystem has
not been populated yet or the last collection failed.
Attributes:
SUBSYSTEMS: Ordered list of subsystem names.
_data: Dict mapping subsystem names to their state data.
"""
SUBSYSTEMS: ClassVar[list[str]] = [
"firewall",
"dnsmasq",
"nginx",
"acme",
"wireguard",
"networkd",
]
def __init__(self) -> None:
"""Initialize the state store with empty subsystem slots."""
self._data: dict[str, dict[str, Any] | None] = {
name: None for name in self.SUBSYSTEMS
}
self._versions: dict[str, int] = {name: 0 for name in self.SUBSYSTEMS}
self._last_broadcast: dict[str, int] | None = None
def bump(self, subsystem: str) -> None:
"""Increment the version counter for *subsystem*.
Args:
subsystem: Subsystem name.
"""
if subsystem in self._versions:
self._versions[subsystem] += 1
def get_versions(self) -> dict[str, int]:
"""Return a shallow copy of all subsystem versions.
Returns:
Dict mapping subsystem names to their current version integers.
"""
return dict(self._versions)
def get_updated_versions(self) -> dict[str, int]:
"""Return versions that changed since the last broadcast.
After calling, ``_last_broadcast`` is updated to match current versions.
Returns:
Dict of subsystems whose versions changed, or empty dict.
"""
if self._last_broadcast is None:
self._last_broadcast = dict(self._versions)
return {}
updated: dict[str, int] = {}
for name, v in self._versions.items():
if v != self._last_broadcast.get(name, 0):
updated[name] = v
self._last_broadcast[name] = v
return updated
def get(self, subsystem: str) -> dict[str, Any] | None:
"""Get state data for *subsystem*.
Args:
subsystem: Subsystem name.
Returns:
State dict, or ``None`` if not populated.
"""
return self._data.get(subsystem)
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
"""Set state data for *subsystem*.
Args:
subsystem: Subsystem name.
data: State data, or ``None`` to clear.
"""
self._data[subsystem] = data
def populate(self, subsystems: list[str] | None = None) -> None:
"""Collect state for *subsystems* (all if ``None``).
Args:
subsystems: List of subsystem names to collect. Collects all
subsystems when ``None``.
"""
targets = subsystems or self.SUBSYSTEMS
for name in targets:
collector = _COLLECTORS.get(name)
if collector is None:
continue
try:
self._data[name] = collector()
except Exception:
logger.warning(
"State collection failed for %s, clearing state",
name,
exc_info=True,
)
self._data[name] = None
def is_populated(self) -> bool:
"""Check whether all subsystem states have been populated.
Returns:
``True`` if every subsystem has non-``None`` state data.
"""
return all(v is not None for v in self._data.values())
def poll(self, subsystem: str) -> tuple[bool, bool]:
"""Run the collector for *subsystem* and compare against current state.
The polled equivalent of ``populate()`` — same try/except safety,
but with two-layer diff before storing.
Args:
subsystem: Subsystem name to poll.
Returns:
``(structural_changed, volatile_changed)``. ``(False, False)`` on
collector failure (no broadcast on failure to avoid noisy ticks).
"""
collector = _COLLECTORS.get(subsystem)
if collector is None:
return (False, False)
vol = _VOLATILE.get(subsystem, frozenset())
try:
new_data = collector()
except Exception:
logger.warning(
"Poll collection failed for %s",
subsystem,
exc_info=True,
)
return (False, False)
old_data = self._data.get(subsystem)
structural, volatile = _diff_layers(old_data, new_data, vol)
self._data[subsystem] = new_data
return (structural, volatile)
def poll_all(
self,
intervals: dict[str, int] | None = None,
) -> dict[str, tuple[bool, bool]]:
"""Poll all subsystems that have a polling interval configured.
Args:
intervals: Subsystems to poll keyed by name. Defaults to
``_DEFAULT_POLL_INTERVALS``.
Returns:
Dict of ``{subsystem: (structural, volatile)}`` for each polled
subsystem.
"""
targets = intervals or _DEFAULT_POLL_INTERVALS
results: dict[str, tuple[bool, bool]] = {}
for name in targets:
results[name] = self.poll(name)
return results
# Singleton
state = State()
# ---------------------------------------------------------------------------
# Collector registry
# ---------------------------------------------------------------------------
_COLLECTORS: dict[str, Any] = {}
_VOLATILE: dict[str, frozenset[str]] = {}
def register_collector(subsystem: str, fn: Any) -> Any:
"""Register *fn* as the state collector for *subsystem*.
Args:
subsystem: Subsystem name to register for.
fn: Collector function to register.
Returns:
The *fn* function (for decorator usage).
"""
_COLLECTORS[subsystem] = fn
return fn
def register_volatile(subsystem: str, keys: frozenset[str]) -> None:
"""Register volatile field paths for *subsystem*.
Args:
subsystem: Subsystem name.
keys: Frozenset of dot-separated volatile field paths
(e.g. ``status.peers[].transfer_received``).
"""
_VOLATILE[subsystem] = keys
# ---------------------------------------------------------------------------
# Two-layer diff
# ---------------------------------------------------------------------------
def _strip_volatile(
data: dict[str, Any],
volatile: frozenset[str],
pop_keys: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Return a copy of *data* with volatile fields zeroed out.
For list-of-dicts fields (``key[].subkey``), strips the volatile sub-keys
from each dict in the list. For scalar/dict fields, sets them to ``None``.
Args:
data: State dict to process.
volatile: Frozenset of dot-separated volatile field paths.
pop_keys: Optional keys to remove from the root dict before stripping.
Returns:
A new dict with volatile fields replaced by ``None``.
"""
stripped = deepcopy(data)
if pop_keys:
for k in pop_keys:
stripped.pop(k, None)
for vpath in volatile:
# Determine if this is a list-of-dicts pattern
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker != -1:
# Split into prefix (before []), item keys (after [])
prefix = vpath[:list_marker].split(".")
item_keys = (
vpath[list_marker + 3 :].split(".")
if list_marker + 3 < len(vpath)
else []
)
parent = stripped
for seg in prefix:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
if isinstance(parent, list):
items = parent
elif isinstance(parent, dict):
logger.debug(
"_strip_volatile: %s resolved to dict, falling back to .values()",
vpath,
)
items = parent.values()
else:
continue
for item in items:
if isinstance(item, dict):
curr = item
for i, ik in enumerate(item_keys):
if i == len(item_keys) - 1:
curr[ik] = None
else:
if isinstance(curr, dict) and ik in curr:
curr = curr[ik]
else:
break
else:
# Scalar/dict path
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
if i == len(segments) - 1:
if isinstance(parent, dict) and seg in parent:
parent[seg] = None
else:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
return stripped
def _diff_layers(
old: dict[str, Any] | None,
new: dict[str, Any],
volatile: frozenset[str],
) -> tuple[bool, bool]:
"""Compare *old* and *new* state using two-layer diff.
Strips ``timestamp`` from both before comparing.
Returns:
``(structural_changed, volatile_changed)`` —
``True`` means that layer differs between old and new.
If structural data changed, volatile is always ``False``
(the structural change already triggers a full re-fetch, so
the volatile signal is suppressed).
"""
if old is None:
return (True, True)
# Structural diff: compare with volatile/timestamp fields zeroed
pop_keys = frozenset(("timestamp",))
old_struct = _strip_volatile(old, volatile, pop_keys)
new_struct = _strip_volatile(new, volatile, pop_keys)
structural = old_struct != new_struct
# Volatile diff: compare without timestamp
volatile_changed = False
if not structural:
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
new_no_ts = {k: v for k, v in new.items() if k != "timestamp"}
volatile_changed = old_no_ts != new_no_ts
return (structural, volatile_changed)
# ---------------------------------------------------------------------------
# Firewall collector
# ---------------------------------------------------------------------------
def _now_iso() -> str:
"""Return the current UTC time as an ISO 8601 string."""
return datetime.now(UTC).isoformat()
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() -> dict[str, Any]:
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
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())
zones: dict[str, dict[str, Any]] = {}
for zn in zone_names:
try:
zones[zn] = _parse_zone_output(
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
)
except Exception:
continue
# Load config
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = load_json(fw_config_path)
# Pending changes
full_state = {
"active_zones": active,
"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)
return {
"active_zones": active,
"interfaces": ifaces,
"available_services": services,
"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",
}
),
)
# ---------------------------------------------------------------------------
# DNSMasq collector
# ---------------------------------------------------------------------------
def _collect_dnsmasq() -> dict[str, Any]:
"""Collect dnsmasq status, config, and leases.
Returns:
Dict containing config, service status, leases, and timestamp.
"""
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {"ranges": [], "static_leases": []},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
}
# Load config
cfg: dict[str, Any] = {}
if CONFIG_PATH.exists():
try:
raw = load_json(CONFIG_PATH)
if raw:
from lib.common import deep_merge
cfg = deep_merge(deepcopy(DEFAULT_CFG), raw)
else:
cfg = deepcopy(DEFAULT_CFG)
except Exception:
cfg = deepcopy(DEFAULT_CFG)
else:
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_at": ts,
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
)
except RuntimeError:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
return {
"config": cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
},
"leases": leases,
"timestamp": _now_iso(),
}
register_collector("dnsmasq", _collect_dnsmasq)
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
# ---------------------------------------------------------------------------
# Nginx collector
# ---------------------------------------------------------------------------
def _collect_nginx() -> dict[str, Any]:
"""Collect nginx config and domains list.
Returns:
Dict containing config, domains, and timestamp.
"""
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
CONFIG_FILE = CONFIG_DIR / "config.json"
SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-RSA-CHACHA20-POLY1305"
),
"prefer_server_ciphers": False,
}
default_cfg: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL),
}
cfg = deepcopy(default_cfg)
if CONFIG_FILE.exists():
try:
raw = load_json(CONFIG_FILE)
if raw:
from lib.common import deep_merge
cfg = deep_merge(default_cfg, raw)
if "ssl" not in cfg:
cfg["ssl"] = deepcopy(DEFAULT_SSL)
except Exception:
pass
# Build domains list with site existence
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
domains.append(
{
"domain": name,
"backend": dom.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
}
)
return {
"config": cfg,
"domains": domains,
"timestamp": _now_iso(),
}
register_collector("nginx", _collect_nginx)
# ---------------------------------------------------------------------------
# ACME collector
# ---------------------------------------------------------------------------
def _find_acme() -> str:
"""Locate the ``acme.sh`` binary on the filesystem.
Returns:
Absolute path to the ``acme.sh`` executable.
Raises:
FileNotFoundError: If acme.sh cannot be found.
"""
acme_home = PROJECT_DIR / "data" / "acme"
candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")]
for path in candidates:
if path.is_file() and os.access(path, os.X_OK):
return str(path)
acme = shutil.which("acme.sh")
if acme:
return acme
raise FileNotFoundError("acme.sh not found")
def _run_acme(args: list[str]) -> str:
"""Run ``acme.sh`` with *args* and return combined output.
Args:
args: Command-line arguments to pass after the home/config flags.
Returns:
Combined stdout/stderr output.
Raises:
RuntimeError: If acme.sh exits non-zero or times out.
"""
acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
_ACME_ENVIRON = {
"HOME": str(PROJECT_DIR),
"PATH": os.environ.get(
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
}
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
env={**os.environ, **_ACME_ENVIRON},
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
output = result.stdout
if result.stderr:
output = output + result.stderr if output else result.stderr
if result.returncode != 0:
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
return output
def _days_until(date_str: str) -> int | None:
"""Parse a date string and return days until *date_str* from now.
Args:
date_str: Date string in common ACME formats.
Returns:
Number of days remaining, or ``None`` if empty or unparseable.
"""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
return (dt - datetime.now(UTC)).days
except ValueError:
continue
try:
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
return (dt - datetime.now(UTC)).days
except ValueError:
pass
return None
def _parse_acme_list_output(raw: str) -> list[dict]:
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
Args:
raw: Raw output string from ``acme.sh --list``.
Returns:
List of dicts with certificate entry fields.
"""
entries: list[dict] = []
for line in raw.strip().splitlines():
line = line.strip()
if not line:
continue
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
if entry:
entries.append(entry)
return entries
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh .account.conf and return account status dict.
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 the file is missing or keys are absent,
``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)
account_path = acme_home / ".account.conf"
default = {
"registered": False,
"email": "",
"ca": "",
"key_length": None,
}
if not account_path.is_file():
return default
try:
text = account_path.read_text()
except OSError:
return default
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 not email or not ca_raw:
return default
ca_map = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
ca = ca_map.get(ca_raw, ca_raw)
return {
"registered": True,
"email": email,
"ca": ca,
"key_length": key_length,
}
def _has_auto_renew(domain: str) -> bool:
"""Check whether *domain* has an auto-renew configuration file.
Args:
domain: Domain name to check.
Returns:
``True`` if a corresponding ``acme.sh`` config file exists.
"""
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
return bool(Path(acme_home_env) / f"{domain}.conf")
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() -> dict[str, Any]:
"""Collect ACME certificate list and email.
Returns:
Dict containing certificate details and registered email.
"""
email = _get_acme_email()
certs: list[dict[str, Any]] = []
try:
raw = _run_acme(["--list"])
entries = _parse_acme_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
main = entry.get("main_domain", "")
if not main:
continue
san_domains = [
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
]
cert_dir = acme_home / main
days = _days_until(entry.get("certificate_expires", ""))
certs.append(
{
"domain": main,
"issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"days_until_expiry": days,
"auto_renew": _has_auto_renew(main),
"san_domains": san_domains,
}
)
except Exception:
pass
account = _parse_account_conf()
return {
"certs": certs,
"email": email,
"account": account,
"timestamp": _now_iso(),
}
register_collector("acme", _collect_acme)
# ---------------------------------------------------------------------------
# WireGuard collector
# ---------------------------------------------------------------------------
def _collect_wireguard() -> dict[str, Any]:
"""Collect WireGuard config, status, and peers.
Returns:
Dict containing interface config, runtime status, and peers.
"""
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
from lib.common import deep_merge
cfg: dict[str, Any] = deepcopy(DEFAULT_CONFIG)
if CONFIG_PATH.exists():
try:
raw = load_json(CONFIG_PATH)
if raw:
cfg = deep_merge(deepcopy(DEFAULT_CONFIG), raw)
except Exception:
pass
# Safe config (strip private key)
safe = dict(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
# 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
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
name = cfg["interface"]["name"]
peer_name = name if isinstance(name, str) else "wg0"
try:
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
if res.returncode == 0:
raw = res.stdout.strip()
current_peer: dict[str, Any] | None = None
status_peers: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
status["up"] = True
status["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
status["interface"]["listen_port"] = int(
line.split(":", 1)[1].strip()
)
continue
if line.startswith("fwmark:"):
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
continue
if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip()
current_peer = {
"public_key": cur_key,
"endpoint": None,
"allowed_ips": [],
"latest_handshake": None,
"transfer_received": "0",
"transfer_sent": "0",
"persistent_keepalive": None,
}
status_peers.append(current_peer)
continue
if current_peer is None:
continue
if line.startswith("endpoint:"):
current_peer["endpoint"] = line.split(":", 1)[1].strip()
elif line.startswith("allowed ips:"):
current_peer["allowed_ips"] = (
line.split(":", 1)[1].strip().split(", ")
)
elif line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip().split(", ")
if rest:
current_peer["transfer_received"] = rest[0].strip()
if len(rest) > 1:
current_peer["transfer_sent"] = rest[1].strip()
elif line.startswith("persistent-keepalive:"):
with contextlib.suppress(ValueError):
current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip()
)
status["peers"] = status_peers
except Exception:
pass
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",
}
),
)
# ---------------------------------------------------------------------------
# Networkd collector
# ---------------------------------------------------------------------------
def _collect_networkd() -> dict[str, Any]:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
"""
result: dict[str, dict[str, Any]] = {}
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {"interfaces": {}, "timestamp": _now_iso()}
except Exception:
return {
"interfaces": {},
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
__all__ = [
"_DEFAULT_POLL_INTERVALS",
"State",
"_diff_layers",
"_strip_volatile",
"register_volatile",
"state",
]