""" dnsmasq configuration manager for Vacuum Wall SSL proxy firewall. Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range, static leases, and custom DNS records through sudo. """ import json import logging import os import subprocess from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().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, ) # --- defaults --- DEFAULT_CFG: dict[str, Any] = { "dhcp": { "ranges": [], "static_leases": [], }, "dns": { "upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": [], }, } # ───────── helpers ─────────────────────────────────────────────────── def _ensure_dirs() -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True) FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True) def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["sudo", *list(cmd)], capture_output=True, text=True, check=True, ) def _load_json(path: Path) -> dict: if not path.exists(): return {} with open(path) as f: return json.load(f) def _save_json(path: Path, data: dict) -> None: _ensure_dirs() with open(path, "w") as f: json.dump(data, f, indent=4) def _deep_merge(base: dict, overrides: dict) -> dict: result = deepcopy(base) for k, v in overrides.items(): if k in result and isinstance(result[k], dict) and isinstance(v, dict): result[k] = _deep_merge(result[k], v) else: result[k] = deepcopy(v) return result # ───────── config lifecycle ────────────────────────────────────────── def get_config() -> dict: """Load current dnsmasq config from JSON state file.""" _ensure_dirs() raw = _load_json(CONFIG_PATH) if not raw: return deepcopy(DEFAULT_CFG) return _deep_merge(deepcopy(DEFAULT_CFG), raw) def save_config(cfg: dict) -> None: """Persist config to JSON (does NOT touch on-disk dnsmasq config).""" _ensure_dirs() merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg) _save_json(CONFIG_PATH, merged) logger.info("dnsmasq config saved") def apply_config() -> None: """Write generated config to disk via sudo tee, then reload dnsmasq.""" cfg = get_config() conf_text = generate_conf(cfg) _ensure_dirs() _sudo("mkdir", "-p", "/etc/dnsmasq.d") subprocess.run( ["sudo", "tee", DNSMASQ_CONF, "--"], input=conf_text, capture_output=True, text=True, check=True, ) _sudo("systemctl", "reload", "dnsmasq") logger.info("dnsmasq config written and reloaded") # ───────── config generation ───────────────────────────────────────── def generate_conf(cfg: dict) -> str: """Render a complete dnsmasq.conf text block from the 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 ] tmpl = ENV.get_template("dnsmasq.conf") return tmpl.render( timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), interfaces=interfaces, dhcp=dhcp_cfg, dns=dns_cfg, fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None, ) # ───────── dhcp management ─────────────────────────────────────────── def set_dhcp_range( iface: str, start: str, end: str, lease_time: str = "12h", gateway: str | None = None, dns: str | None = None, ) -> None: """Add or replace the DHCP range for a given interface.""" 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 dns: ranges[i]["dns"] = 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 dns: entry["dns"] = dns ranges.append(entry) save_config(cfg) logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end) def remove_dhcp_range(iface: str, start: str, end: str) -> None: """Remove a DHCP range by interface + IP range.""" cfg = get_config() cfg["dhcp"]["ranges"] = [ r for r in cfg["dhcp"]["ranges"] if not ( r.get("interface") == iface and r.get("start") == start and r.get("end") == end ) ] save_config(cfg) logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end) def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None: """Add (or update) a static DHCP lease by MAC address.""" cfg = get_config() leases = cfg["dhcp"]["static_leases"] for i, lease in enumerate(leases): if lease["mac"].lower() == mac.lower(): leases[i] = {"mac": mac, "ip": ip} if hostname: leases[i]["hostname"] = hostname save_config(cfg) logger.info("Static DHCP lease updated: %s -> %s", mac, ip) return entry: dict[str, Any] = {"mac": mac, "ip": ip} if hostname: entry["hostname"] = hostname leases.append(entry) save_config(cfg) logger.info("Static DHCP lease added: %s -> %s", mac, ip) def remove_static_lease(mac: str) -> None: """Remove a static DHCP lease by MAC address.""" cfg = get_config() cfg["dhcp"]["static_leases"] = [ lease for lease in cfg["dhcp"]["static_leases"] if lease["mac"].lower() != mac.lower() ] save_config(cfg) logger.info("Static DHCP lease removed for MAC %s", mac) # ───────── dns record management ───────────────────────────────────── def add_dns_record(name: str, address: str, hostname: str | None = None) -> None: """Add or update a custom DNS A record.""" cfg = get_config() records = cfg["dns"]["custom_records"] for i, r in enumerate(records): if r["name"] == name: records[i] = {"name": name, "address": address} if hostname: records[i]["hostname"] = hostname save_config(cfg) logger.info("DNS record updated: %s -> %s", name, address) return entry: dict[str, Any] = {"name": name, "address": address} if hostname: entry["hostname"] = hostname records.append(entry) save_config(cfg) logger.info("DNS record added: %s -> %s", name, address) def remove_dns_record(name: str) -> None: """Remove a custom DNS record by name.""" cfg = get_config() cfg["dns"]["custom_records"] = [ r for r in cfg["dns"]["custom_records"] if r["name"] != name ] save_config(cfg) logger.info("DNS record removed: %s", name) # ───────── lease table ─────────────────────────────────────────────── def _parse_lease_line(line: str) -> dict[str, Any] | None: """Parse one line from dnsmasq.leases into a dict.""" line = line.strip() if not line or line.startswith("#"): return None parts = line.split() if len(parts) < 3: return None try: ts = datetime.fromtimestamp(int(parts[0]), tz=UTC) except (ValueError, OSError): ts = None return { "expires_at": ts, "mac": parts[1], "ip": parts[2], "hostname": parts[3] if len(parts) > 3 else "", "interface": parts[4] if len(parts) > 4 else "", } def get_lease_table() -> list[dict]: """Read and parse the current dnsmasq lease file.""" leases: list[dict] = [] try: result = _sudo("cat", LEASE_FILE) for entry in map(_parse_lease_line, result.stdout.splitlines()): if entry is not None: leases.append(entry) except subprocess.CalledProcessError: pass return leases # ───────── upstream / domain helpers ───────────────────────────────── def set_upstreams(servers: list[str]) -> None: """Set the list of upstream DNS forwarders.""" cfg = get_config() cfg["dns"]["upstreams"] = list(servers) save_config(cfg) logger.info("DNS upstreams set to %s", servers) def set_domain(domain: str | None) -> None: """Set (or clear) the local DNS domain.""" cfg = get_config() cfg["dns"]["domain"] = domain if domain else None save_config(cfg) logger.info("DNS domain set to '%s'", domain) # ───────── status / info ───────────────────────────────────────────── def get_status() -> dict: """Return service status, config summary, and current lease count.""" cfg = get_config() try: proc = subprocess.run( ["sudo", "systemctl", "is-active", "dnsmasq"], capture_output=True, text=True, ) active = proc.stdout.strip() == "active" except Exception: active = False conf_exists = os.path.isfile(DNSMASQ_CONF) if conf_exists: try: with open(DNSMASQ_CONF) as f: conf_on_disk = f.read() except PermissionError: conf_on_disk = "" else: conf_on_disk = "" expected = generate_conf(cfg) leases = get_lease_table() return { "service_active": active, "config_file_exists": conf_exists, "config_in_sync": conf_on_disk == expected, "dhcp_ranges": len(cfg["dhcp"]["ranges"]), "static_leases": len(cfg["dhcp"]["static_leases"]), "custom_dns_records": len(cfg["dns"]["custom_records"]), "upstreams": cfg["dns"]["upstreams"], "domain": cfg["dns"].get("domain"), "active_leases": len(leases), "leases": leases, }