Files
vacuum-wall/daemon/handlers/dnsmasq.py
T
mteehan 30b51ad7d3 state: applied-config snapshots + per-field pending diffs
- lib/common: stamp_applied() now records a _last_applied_config
  snapshot alongside the hash; strip_apply_meta() centralizes
  bookkeeping-key stripping; deep_diff() reports field-level changes
- state collectors (dnsmasq/nginx/wireguard/networkd) expose
  pending_diff so the dashboard can show exactly which fields
  changed since the last apply (wireguard diff excludes
  private_key paths)
- dashboard pending-changes card renders per-change lines with a
  generic fallback when no snapshot is recorded
- firewall: firewalld built-in zones no longer flagged as
  unmanaged; public-zone masquerade skipped in pending changes
  since apply drives it via nftables propagation
- schema: PendingChange TypedDict; pending_diff on DnsmasqStatus /
  WgStatus; tests in test_common.py, test_firewall.py, test_state.py
2026-08-21 00:59:19 +00:00

531 lines
17 KiB
Python

"""Dnsmasq daemon handler."""
import logging
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
GET_DNSMASQ_CONFIG,
GET_DNSMASQ_LEASES,
GET_DNSMASQ_STATUS,
PATCH_DNSMASQ_CONFIG,
POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_DOMAIN,
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import (
deep_merge,
ensure_dirs,
get_interface_ip,
load_json,
run,
save_json,
stamp_applied,
strip_apply_meta,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {"ranges": [], "static_leases": []},
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
}
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store."""
from lib.state import state as state_store
return state_store.get("dnsmasq")
def _get_config() -> dict[str, Any]:
"""Load dnsmasq config from JSON, merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return deep_merge(deepcopy(DEFAULT_CFG), raw)
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist dnsmasq config to JSON after merging with defaults."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render dnsmasq.conf from Jinja template and config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
# Fallback: use network-managed interface addresses for listen-address
listen_addresses = []
try:
from lib.network import get_config as _get_net_config
net_cfg = _get_net_config()
for _iface, info in net_cfg.get("interfaces", {}).items():
for addr_str in info.get("addresses", []):
if "/" in addr_str:
addr_str = addr_str.split("/")[0]
listen_addresses.append(addr_str)
except Exception:
pass
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interfaces=interfaces or None,
listen_addresses=listen_addresses if listen_addresses else None,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
)
def _get_dnsmasq_state() -> dict[str, Any]:
"""Return cached dnsmasq state, or empty dict if unset."""
dm = _get_state()
if dm is None:
return {}
return dm
# ---------------------------------------------------------------------------
# Routes
@registry.register(GET_DNSMASQ_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/config
Returns cached config if available, otherwise loads from disk.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
cfg = _get_config()
return strip_apply_meta(cfg)
@registry.register(POST_DNSMASQ_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/config
Save full config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True}
@registry.register(PATCH_DNSMASQ_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: PATCH /dnsmasq/config
Merge partial update into existing config. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True}
@registry.register(POST_DNSMASQ_APPLY)
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/apply
Render config to dnsmasq.conf, write to disk, and reload dnsmasq service via sudo.
"""
cfg = _get_config()
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
tmp = Path("/run/vacuum-wall/dnsmasq.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(conf_text)
run(["cp", "--", str(tmp), str(DNSMASQ_CONF)], sudo=True)
tmp.unlink(missing_ok=True)
run(["systemctl", "restart", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and restarted")
# Store the applied config snapshot + hash so the state collector can
# detect pending changes and report what specifically changed.
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
@registry.register(GET_DNSMASQ_STATUS)
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
"""\
Endpoint: GET /dnsmasq/status
Returns cached dnsmasq status object, or empty dict if unavailable.
"""
dm = _get_dnsmasq_state()
if dm and "status" in dm:
return dm["status"]
return {}
@registry.register(POST_DNSMASQ_RANGES_ADD)
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/ranges/add
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
Auto-populates ``gateway`` from the interface's IPv4 address when not provided.
"""
if not body:
raise ValueError("Request body required")
iface = (body.get("interface") or "").strip()
start = body.get("start", "").strip()
end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h")
if not start or not end:
raise ValueError("'start' and 'end' are required")
# Resolve gateway: explicit value > existing range value > interface IP
gateway = body.get("gateway")
if not gateway:
# Check existing range for same interface
cfg_tmp = _get_config()
for r in cfg_tmp["dhcp"]["ranges"]:
if r.get("interface") == iface:
gateway = r.get("gateway")
break
# Fall back to interface's own IP
if not gateway and iface:
gateway = get_interface_ip(iface)
cfg = _get_config()
ranges = cfg["dhcp"]["ranges"]
found = False
for i, r in enumerate(ranges):
if r.get("interface") == iface:
ranges[i] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
ranges[i]["gateway"] = gateway
if body.get("dns"):
ranges[i]["dns"] = body["dns"]
found = True
break
if not found:
entry: dict[str, Any] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
entry["gateway"] = gateway
if body.get("dns"):
entry["dns"] = body["dns"]
ranges.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end}
@registry.register(DELETE_DNSMASQ_RANGES_REMOVE)
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/ranges/remove
Remove DHCP range matching interface + start + end. Raises NotFoundError if missing.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
end = body.get("end", "").strip()
if not start or not end:
raise ValueError("'start' and 'end' are required")
cfg = _get_config()
ranges = cfg["dhcp"]["ranges"]
before = len(ranges)
cfg["dhcp"]["ranges"] = [
r
for r in ranges
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
if len(cfg["dhcp"]["ranges"]) == before:
raise NotFoundError(
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end}
@registry.register(GET_DNSMASQ_LEASES)
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""\
Endpoint: GET /dnsmasq/leases
Returns cached DHCP lease list from state, or empty list if unavailable.
"""
dm = _get_dnsmasq_state()
if dm:
return dm.get("leases", [])
return []
@registry.register(POST_DNSMASQ_STATIC_LEASE_ADD)
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/static-lease/add
Add or update a static DHCP lease by MAC address. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
ip = body.get("ip", "").strip()
hostname = body.get("hostname")
if not mac or not ip:
raise ValueError("'mac' and 'ip' are required")
cfg = _get_config()
leases = cfg["dhcp"]["static_leases"]
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
leases[i].update({"mac": mac, "ip": ip})
if hostname is not None:
leases[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname}
@registry.register(DELETE_DNSMASQ_STATIC_LEASE_REMOVE)
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/static-lease/remove
Remove static lease by MAC. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
mac = body.get("mac", "").strip()
if not mac:
raise ValueError("'mac' is required")
cfg = _get_config()
leases = cfg["dhcp"]["static_leases"]
before = len(leases)
cfg["dhcp"]["static_leases"] = [
lease for lease in leases if lease["mac"].lower() != mac.lower()
]
if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac}
@registry.register(POST_DNSMASQ_DNS_RECORD_ADD)
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/dns-record/add
Add or update a custom DNS record by name. Raises ValueError on invalid input.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
address = body.get("address", "").strip()
hostname = body.get("hostname")
if not name or not address:
raise ValueError("'name' and 'address' are required")
cfg = _get_config()
records = cfg["dns"]["custom_records"]
for i, r in enumerate(records):
if r["name"] == name:
records[i].update({"name": name, "address": address})
if hostname is not None:
records[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname}
@registry.register(DELETE_DNSMASQ_DNS_RECORD_REMOVE)
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: DELETE /dnsmasq/dns-record/remove
Remove custom DNS record by name. Raises NotFoundError if no match.
"""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
records = cfg["dns"]["custom_records"]
before = len(records)
cfg["dns"]["custom_records"] = [r for r in records if r["name"] != name]
if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name}
@registry.register(POST_DNSMASQ_UPSTREAMS)
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/upstreams
Replace DNS upstream servers list. Raises ValueError if servers field missing.
"""
if not body or "servers" not in body:
raise ValueError("'servers' is required")
cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"upstreams": cfg["dns"]["upstreams"]}
@registry.register(POST_DNSMASQ_DOMAIN)
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""\
Endpoint: POST /dnsmasq/domain
Set or clear the local DNS domain. Raises ValueError on missing body.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain")
cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"domain": cfg["dns"]["domain"]}