37039351be
- wireguard: POST /peers with JSON encoding (was /add-peer) - rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render - nat: port forward delete uses URL path params to match blueprint - nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch) - app.js renderers updated to use URL path deletes for rules and forwards - remove TODO.md
384 lines
11 KiB
Python
384 lines
11 KiB
Python
"""
|
|
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,
|
|
}
|