442 lines
14 KiB
Python
442 lines
14 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.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": []},
|
|
}
|
|
|
|
|
|
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)
|
|
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")
|
|
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"]}
|