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.
86 lines
2.5 KiB
Python
86 lines
2.5 KiB
Python
"""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
|