"""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