"""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 logging import subprocess from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader from lib.common import deep_merge, ensure_dirs, load_json, save_json 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": [], }, } # ───────── config lifecycle ────────────────────────────────────────── def get_config() -> dict[str, Any]: """Load current dnsmasq config from JSON state file.""" 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 config to JSON (does NOT touch on-disk dnsmasq config).""" ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) 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(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True) subprocess.run( ["sudo", "tee", DNSMASQ_CONF, "--"], input=conf_text, capture_output=True, text=True, check=True, ) subprocess.run( ["sudo", "systemctl", "reload", "dnsmasq"], capture_output=True, text=True, check=True, ) logger.info("dnsmasq config written and reloaded") # ───────── config generation ───────────────────────────────────────── def generate_conf(cfg: dict[str, Any]) -> 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 ] # 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, ) # ───────── 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].update({"mac": mac, "ip": ip}) if hostname is not None: 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].update({"name": name, "address": address}) if hostname is not None: 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[str, Any]]: """Read and parse the current dnsmasq lease file.""" leases: list[dict[str, Any]] = [] try: result = subprocess.run( ["sudo", "cat", LEASE_FILE], capture_output=True, text=True, check=True, ) 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[str, Any]: """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 = Path(DNSMASQ_CONF).is_file() 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, } __all__ = [ "add_dns_record", "add_static_lease", "apply_config", "generate_conf", "get_config", "get_lease_table", "get_status", "remove_dhcp_range", "remove_dns_record", "remove_static_lease", "save_config", "set_dhcp_range", "set_domain", "set_upstreams", ]