Files
vacuum-wall/daemon/handlers/dnsmasq.py
T
mteehan 200e078bc5 refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
2026-05-27 23:39:33 +00:00

365 lines
12 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.server import NotFoundError, 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": []},
}
_DNSMASQ_TAGS = {"dnsmasq"}
def _get_config() -> dict[str, Any]:
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:
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:
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,
)
def _parse_lease_line(line: str) -> dict[str, Any] | None:
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]]:
leases: list[dict[str, Any]] = []
try:
result = run_proc(
["cat", LEASE_FILE],
sudo=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
except RuntimeError:
pass
return leases
@registry.register("GET", "/dnsmasq/config", cache_tags=_DNSMASQ_TAGS)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
@registry.register("POST", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
_save_config(body)
return {"config_saved": True}
@registry.register("PATCH", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
return {"config_saved": True}
@registry.register("POST", "/dnsmasq/apply", invalidate=_DNSMASQ_TAGS)
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config()
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
run_proc(
["tee", DNSMASQ_CONF, "--"],
sudo=True,
check=True,
input=conf_text,
)
run(["systemctl", "reload", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and reloaded")
return {"applied": True}
@registry.register("GET", "/dnsmasq/status", cache_tags=_DNSMASQ_TAGS)
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config()
try:
proc = run_proc(
["systemctl", "is-active", "dnsmasq"], sudo=True
)
active = proc.stdout.strip() == "active"
except Exception:
active = False
conf_exists = Path(DNSMASQ_CONF).is_file()
conf_on_disk = ""
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
conf_on_disk = f.read()
except PermissionError:
pass
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,
}
@registry.register("POST", "/dnsmasq/ranges/add", invalidate=_DNSMASQ_TAGS)
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"interface": iface, "start": start, "end": end}
@registry.register("DELETE", "/dnsmasq/ranges/remove", invalidate=_DNSMASQ_TAGS)
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"interface": iface, "start": start, "end": end}
@registry.register("GET", "/dnsmasq/leases", cache_tags=_DNSMASQ_TAGS)
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
return _get_lease_table()
@registry.register("POST", "/dnsmasq/static-lease/add", invalidate=_DNSMASQ_TAGS)
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
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)
return {"mac": mac, "ip": ip, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/static-lease/remove", invalidate=_DNSMASQ_TAGS)
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"mac": mac}
@registry.register("POST", "/dnsmasq/dns-record/add", invalidate=_DNSMASQ_TAGS)
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
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)
return {"name": name, "address": address, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/dns-record/remove", invalidate=_DNSMASQ_TAGS)
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"name": name}
@registry.register("POST", "/dnsmasq/upstreams", invalidate=_DNSMASQ_TAGS)
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"upstreams": cfg["dns"]["upstreams"]}
@registry.register("POST", "/dnsmasq/domain", invalidate=_DNSMASQ_TAGS)
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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)
return {"domain": cfg["dns"]["domain"]}