"""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 ( _APPLY_HASH_KEY, config_hash, deep_merge, ensure_dirs, get_interface_ip, load_json, run, save_json, ) 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 {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} @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 config hash so state collector can detect pending changes cfg_after = _get_config() cfg_after[_APPLY_HASH_KEY] = config_hash(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"]}