"""Dnsmasq daemon handler.""" import hashlib 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, load_json, run, run_proc, save_json 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/dnsmasq/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": []}, } # Internal field for tracking applied config version _APPLY_HASH_KEY = "_last_applied_hash" def _config_hash(cfg: dict[str, Any]) -> str: """Compute a hash of the config, excluding the _last_applied_hash field. Used to detect whether the JSON config has changed since the last apply. """ import json clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest() 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", {}) return _get_config() @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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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("/tmp") / "vacuum-wall-dnsmasq.tmp" with open(tmp, "w") as f: f.write(conf_text) run(["cp", str(tmp), DNSMASQ_CONF], sudo=True) tmp.unlink(missing_ok=True) run(["systemctl", "reload", "dnsmasq"], sudo=True) logger.info("dnsmasq config written and reloaded") # 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) refresh_state(["dnsmasq"]) return {"applied": True} @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. """ if not body: raise ValueError("Request body required") iface = body.get("interface", "").strip() or "" 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") 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 body.get("gateway"): ranges[i]["gateway"] = body["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 body.get("gateway"): entry["gateway"] = body["gateway"] if body.get("dns"): entry["dns"] = body["dns"] ranges.append(entry) _save_config(cfg) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) 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) refresh_state(["dnsmasq"]) return {"domain": cfg["dns"]["domain"]}