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
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
"""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"]}
|
||||
Reference in New Issue
Block a user