Development

This commit is contained in:
Matthew Grotke 2026-06-05 01:48:27 -04:00
parent 205d6889df
commit 58ab569e42
27 changed files with 2894 additions and 2605 deletions

626
routlin/mod_dnsmasq.py Normal file
View file

@ -0,0 +1,626 @@
"""
mod_dnsmasq.py -- dnsmasq instance configuration, service management, and leases.
Handles blocklist merging, per-VLAN dnsmasq config and systemd service unit
generation, applying/reloading instances, system service conflict resolution
(systemd-resolved, dnsmasq, chrony, ufw), and DHCP lease display.
"""
import hashlib
import json
import subprocess
from datetime import datetime
from pathlib import Path
import mod_shared as shared
import mod_wireguard as wireguard
import mod_validation as validation
BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists"
RESOLV_CONF = Path("/etc/resolv.conf")
# ===================================================================
# Blocklist management
# ===================================================================
def combo_hash(names):
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
key = ",".join(sorted(names))
return hashlib.sha256(key.encode()).hexdigest()[:8]
def merged_path(h):
return BLOCKLIST_DIR / f"merged-{h}.conf"
def blocklists_available(data):
"""Return True if at least one merged blocklist file exists on disk."""
combos = set()
for vlan in data.get("vlans", []):
names = vlan.get("use_blocklists", [])
if names:
combos.add(combo_hash(names))
return any(merged_path(h).exists() for h in combos)
# ===================================================================
# Build per-VLAN dnsmasq config
# ===================================================================
def _wan_has_ipv6(iface):
"""Return True if the WAN interface has a non-link-local IPv6 address."""
try:
result = subprocess.run(
["ip", "-6", "addr", "show", iface, "scope", "global"],
capture_output=True, text=True
)
return bool(result.stdout.strip())
except Exception:
return False
def get_container_bridge_ips():
"""Return {ifname: ip} for all active container bridge interfaces.
Used to add listen-address directives to the physical VLAN's dnsmasq
instance so containers can reach the local DNS resolver.
Works universally for Docker, Podman, LXC, libvirt, etc.
"""
try:
result = subprocess.run(
["ip", "-j", "addr", "show", "type", "bridge"],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return {}
links = json.loads(result.stdout)
out = {}
for l in links:
if l.get("operstate") != "UP":
continue
for addr in l.get("addr_info", []):
if addr.get("family") == "inet":
out[l["ifname"]] = addr["local"]
break
return out
except Exception:
return {}
def build_vlan_dnsmasq_conf(vlan, data, iface):
"""Generate the complete dnsmasq config for one VLAN instance."""
dns_cfg = data.get("upstream_dns", {})
general = data.get("network_interfaces", {})
overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True]
name = vlan["name"]
d = vlan.get("dhcp_information", {})
opts = shared.resolve_vlan_options(vlan)
gateway = opts["gateway"]
bl_names = vlan.get("use_blocklists", [])
bl_file = None
if bl_names:
p = merged_path(combo_hash(bl_names))
if p.exists():
bl_file = p
L = [
"# Generated by core.py -- do not edit manually.",
"# Edit config.json and re-run: sudo python3 core.py --apply",
f"# VLAN: {name} (vlan_id={vlan.get('vlan_id')})",
"",
]
L.append(f"pid-file={shared.vlan_pid_file(vlan)}")
if not validation.is_wg(vlan):
L.append(f"dhcp-leasefile={shared.vlan_leases_file(vlan)}")
L += [
"except-interface=lo",
"bind-interfaces",
f"listen-address={gateway}",
f"interface={iface}",
]
if shared.is_physical(vlan):
bridge_ips = get_container_bridge_ips()
for bridge, ip in bridge_ips.items():
L += [f"interface={bridge}", f"listen-address={ip}"]
L.append("")
if not validation.is_wg(vlan):
dotted_mask = shared.prefix_to_dotted(vlan['subnet_mask'])
L += [
"# -- DHCP -----------------------------------------------------------",
f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}",
f"domain={d.get('domain', 'local')}",
"",
f"dhcp-option=tag:{name},option:router,{gateway}",
f"dhcp-option=tag:{name},option:dns-server,{opts['dns_servers']}",
f"dhcp-option=tag:{name},option:ntp-server,{opts['ntp_servers']}",
"",
]
identity_hosts = [s for s in vlan.get("server_identities", []) if s.get("hostname")]
if identity_hosts:
L.append("# -- Server identity hostnames ----------------------------------")
for s in identity_hosts:
L += [f"# {s['description']}", f"dhcp-host={s['ip']},{s['hostname']}", ""]
vlan_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == name]
active_res = [r for r in vlan_res if r.get("enabled") is True]
inactive_res = [r for r in vlan_res if r.get("enabled") is not True]
if active_res:
L.append("# -- Reservations -----------------------------------------------")
# Group reservations sharing a static IP into single dhcp-host lines
# (multiple MACs for the same device e.g. wired + WiFi interfaces)
# Dynamic reservations are always emitted individually.
seen_ips = {} # ip -> list of reservations
ordered = [] # preserves insertion order for output
for r in active_res:
if validation.is_dynamic_ip(r):
ordered.append([r]) # always individual
else:
ip = r.get("ip", "")
if ip in seen_ips:
seen_ips[ip].append(r)
else:
seen_ips[ip] = [r]
ordered.append(seen_ips[ip])
for group in ordered:
if len(group) == 1:
r = group[0]
h = r.get('hostname', '')
L.append(f"# {r['description']}")
if validation.is_dynamic_ip(r):
L.append(f"dhcp-host=set:{name},{r['mac']},{h},{d['lease_time']}" if h else
f"dhcp-host=set:{name},{r['mac']},{d['lease_time']}")
else:
L.append(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{h},{d['lease_time']}" if h else
f"dhcp-host=set:{name},{r['mac']},{r['ip']},{d['lease_time']}")
else:
# Multiple MACs share the same IP -- combine into one dhcp-host line
descs = ", ".join(r['description'] for r in group)
macs = ",".join(r['mac'] for r in group)
ip = group[0]['ip']
hostname = group[0].get('hostname', '')
L.append(f"# {descs}")
L.append(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}" if hostname else
f"dhcp-host=set:{name},{macs},{ip},{d['lease_time']}")
L.append("")
if inactive_res:
L.append("# -- Skipped reservations (enabled: false) ----------------------")
for r in inactive_res:
L.append(f"# SKIPPED: {r['description']} ({r.get('mac', '?')} -> {r.get('ip', '?')})")
L.append("")
L += [
"# -- DNS ------------------------------------------------------------",
"no-resolv",
]
if dns_cfg.get("strict_order"):
L.append("strict-order")
wan = data["network_interfaces"]["wan_interface"]
wan_has_ipv6 = _wan_has_ipv6(wan)
for srv in dns_cfg.get("upstream_servers", []):
if ":" in srv and not wan_has_ipv6:
continue # skip IPv6 upstream -- WAN has no IPv6 address
L.append(f"server={srv}")
L.append(f"cache-size={dns_cfg.get('cache_size', 1000)}")
if vlan.get("dnsmasq_log_queries", False):
L.append("log-queries")
L.append("")
if overrides:
L.append("# -- Host overrides -------------------------------------------------")
for o in overrides:
L += [f"# {o['description']}", f"address=/{o['host']}/{o['ip']}", ""]
if bl_file:
L += [
"# -- Blocklist ------------------------------------------------------",
f"conf-file={bl_file}",
"",
]
elif bl_names:
L += ["# Blocklist not yet downloaded -- run: sudo python3 dns-blocklists.py", ""]
return "\n".join(L)
# ===================================================================
# Build per-VLAN systemd service unit
# ===================================================================
def build_vlan_service(vlan, iface):
name = vlan["name"]
conf = shared.vlan_conf_file(vlan)
if validation.is_wg(vlan):
after = f"network-online.target wg-quick@{iface}.service"
wants = "network-online.target"
bindsto = f"wg-quick@{iface}.service"
else:
after = "network-online.target"
wants = "network-online.target"
bindsto = None
lines = [
"# Generated by core.py -- do not edit manually.",
"",
"[Unit]",
f"Description=dnsmasq for VLAN {name}",
f"After={after}",
f"Wants={wants}",
]
if bindsto:
lines.append(f"BindsTo={bindsto}")
lines += [
"",
"[Service]",
"Type=forking",
f"PIDFile={shared.vlan_pid_file(vlan)}",
f"ExecStart=/usr/sbin/dnsmasq --conf-file={conf}",
"ExecReload=/bin/kill -HUP $MAINPID",
"Restart=on-failure",
"RestartSec=5s",
"",
"[Install]",
"WantedBy=multi-user.target",
"",
]
return "\n".join(lines)
# ===================================================================
# System service helpers
# ===================================================================
def ensure_resolv_conf(data):
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
physical = next((v for v in data["vlans"] if shared.is_physical(v)), None)
if physical is None:
return
nameserver = shared.resolve_vlan_options(physical)["gateway"]
wanted = f"nameserver {nameserver}\n"
# A symlink (e.g. to systemd-resolved stub) must be replaced with a plain file.
if RESOLV_CONF.is_symlink():
RESOLV_CONF.unlink()
print("Removed /etc/resolv.conf symlink (was pointing to systemd-resolved stub).")
current = RESOLV_CONF.read_text() if RESOLV_CONF.exists() else ""
if wanted in current:
print(f"/etc/resolv.conf already points to {nameserver}. Good.")
return
RESOLV_CONF.write_text(wanted)
print(f"Updated /etc/resolv.conf: nameserver {nameserver}")
def disable_systemd_resolved():
"""Stop and disable systemd-resolved if it is active."""
result = subprocess.run(
["systemctl", "is-active", "systemd-resolved"],
capture_output=True, text=True
)
if result.stdout.strip() == "active":
subprocess.run(["systemctl", "disable", "--now", "systemd-resolved"],
capture_output=True, text=True)
print("Disabled systemd-resolved.")
else:
print("systemd-resolved is not active. Good.")
def disable_systemd_timesyncd():
"""Stop and disable systemd-timesyncd if it is active."""
result = subprocess.run(
["systemctl", "is-active", "systemd-timesyncd"],
capture_output=True, text=True
)
if result.stdout.strip() == "active":
subprocess.run(["systemctl", "disable", "--now", "systemd-timesyncd"],
capture_output=True, text=True)
print("Disabled systemd-timesyncd.")
else:
print("systemd-timesyncd is not active. Good.")
def ensure_chrony(data):
"""Add VLAN allow directives to chrony.conf and start the service."""
chrony_conf = Path("/etc/chrony/chrony.conf")
if chrony_conf.exists():
content = chrony_conf.read_text()
subnets = []
for v in data["vlans"]:
subnets.append(str(shared.network_for(v)))
added = []
for subnet in subnets:
line = f"allow {subnet}"
if line not in content:
content += f"\n{line}"
added.append(subnet)
if added:
chrony_conf.write_text(content)
print(f"Updated /etc/chrony/chrony.conf: added allow for {', '.join(added)}")
else:
print("chrony.conf already has required allow directives. Good.")
subprocess.run(["systemctl", "enable", "--now", "chrony"],
capture_output=True, text=True)
subprocess.run(["systemctl", "restart", "chrony"],
capture_output=True, text=True)
print("chrony enabled and running. Good.")
def disable_ufw():
"""Disable ufw (without removing it) if it is installed."""
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode != 0:
print("ufw is not installed. Good.")
return
status = subprocess.run(["ufw", "status"], capture_output=True, text=True)
if "Status: active" in status.stdout:
subprocess.run(["ufw", "disable"], capture_output=True, text=True)
print("ufw rules cleared.")
else:
print("ufw is not active. Good.")
svc = subprocess.run(["systemctl", "is-enabled", "ufw"],
capture_output=True, text=True)
if svc.stdout.strip() in ("enabled", "enabled-runtime"):
subprocess.run(["systemctl", "disable", "ufw"], capture_output=True, text=True)
print("Disabled ufw.service.")
def disable_system_dnsmasq(data):
"""Stop and disable the system dnsmasq.service if it is enabled."""
disable_systemd_resolved()
result = subprocess.run(
["systemctl", "is-enabled", "dnsmasq"],
capture_output=True, text=True
)
if result.stdout.strip() in ("enabled", "enabled-runtime"):
subprocess.run(["systemctl", "disable", "--now", "dnsmasq"],
capture_output=True, text=True)
print("Disabled system dnsmasq.service.")
else:
print("System dnsmasq.service is already disabled. Good.")
ensure_resolv_conf(data)
def restore_ntp():
"""Disable chrony and re-enable systemd-timesyncd for plain client NTP."""
result = subprocess.run(
["systemctl", "is-active", "chrony"], capture_output=True, text=True
)
if result.stdout.strip() == "active":
subprocess.run(["systemctl", "disable", "--now", "chrony"],
capture_output=True, text=True)
print("Disabled chrony.")
else:
print("chrony is not active.")
result = subprocess.run(
["systemctl", "cat", "systemd-timesyncd"], capture_output=True, text=True
)
if result.returncode == 0:
subprocess.run(["systemctl", "enable", "--now", "systemd-timesyncd"],
capture_output=True, text=True)
print("Enabled systemd-timesyncd.")
else:
print("systemd-timesyncd is not available on this system.")
# ===================================================================
# Apply dnsmasq instances
# ===================================================================
def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
"""Write per-VLAN dnsmasq configs and service units.
start_if_needed=True: enable and start all instances.
start_if_needed=False (--apply): only restart instances already running;
skip with a warning if not running.
"""
active_service_stems = {shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) for vlan in data["vlans"]}
if not dry_run:
shared.DNSMASQ_CONF_DIR.mkdir(exist_ok=True)
disable_system_dnsmasq(data)
print()
for vlan in data["vlans"]:
iface = validation.derive_interface(vlan, data)
if validation.is_wg(vlan) and not dry_run and not wireguard.wg_interface_up(iface):
print(f"Skipped VLAN '{vlan['name']}': {iface} is not up. Run --apply again after WireGuard is up.")
continue
conf_content = build_vlan_dnsmasq_conf(vlan, data, iface)
svc_content = build_vlan_service(vlan, iface)
conf_path = shared.vlan_conf_file(vlan)
svc_path = shared.vlan_service_file(vlan, iface)
if dry_run:
print(f"# -- {conf_path} (dry-run) --")
print(conf_content)
print(f"# -- {svc_path} (dry-run) --")
print(svc_content)
continue
conf_path.write_text(conf_content)
print(f"Written: {conf_path}")
if not svc_path.exists() or svc_path.read_text() != svc_content:
svc_path.write_text(svc_content)
print(f"Written: {svc_path}")
if dry_run:
return
print()
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
# Remove stale service units (VLANs removed from config)
for f in shared.SYSTEMD_DIR.glob(f"dnsmasq-{shared.PRODUCT_NAME}-*.service"):
if f.stem not in active_service_stems:
subprocess.run(["systemctl", "disable", "--now", f.stem],
capture_output=True, text=True)
f.unlink()
n = f.stem.removeprefix(f"dnsmasq-{shared.PRODUCT_NAME}-")
stale_conf = shared.DNSMASQ_CONF_DIR / f"{n}.conf"
if stale_conf.exists():
stale_conf.unlink()
print(f"Removed stale VLAN: {f.stem}")
if start_if_needed:
print("Starting dnsmasq instances...")
for vlan in data["vlans"]:
iface = validation.derive_interface(vlan, data)
if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface):
continue
svc = shared.vlan_service_name(vlan, iface)
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
result = subprocess.run(["systemctl", "restart", svc],
capture_output=True, text=True)
if result.returncode == 0:
print(f" Started: {svc}")
else:
shared.service_warning("start", svc, result.stderr)
else:
print("Reloading dnsmasq instances...")
for vlan in data["vlans"]:
iface = validation.derive_interface(vlan, data)
if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface):
continue
svc = shared.vlan_service_name(vlan, iface)
state = subprocess.run(
["systemctl", "is-active", svc],
capture_output=True, text=True
).stdout.strip()
if state == "active":
result = subprocess.run(["systemctl", "restart", svc],
capture_output=True, text=True)
if result.returncode == 0:
print(f" Restarted: {svc}")
else:
shared.service_warning("restart", svc, result.stderr)
elif validation.is_wg(vlan):
# WG interface is up but dnsmasq isn't running -- start it now
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
result = subprocess.run(["systemctl", "start", svc],
capture_output=True, text=True)
if result.returncode == 0:
print(f" Started: {svc}")
else:
shared.service_warning("start", svc, result.stderr)
else:
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
# ===================================================================
# Leases
# ===================================================================
def reset_leases(data, vlan_name=None):
"""Stop dnsmasq instances, delete lease files, restart instances.
If vlan_name is given, only reset that VLAN. Otherwise reset all.
"""
vlans = [v for v in data["vlans"] if not validation.is_wg(v)]
if vlan_name:
vlans = [v for v in vlans if v["name"] == vlan_name]
if not vlans:
valid = ', '.join(v['name'] for v in data['vlans'] if not validation.is_wg(v))
return f"Unknown VLAN name '{vlan_name}'. Valid names: {valid}"
print(f"Resetting leases for: {', '.join(v['name'] for v in vlans)}")
print()
# Stop
for vlan in vlans:
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
result = subprocess.run(["systemctl", "stop", svc],
capture_output=True, text=True)
if result.returncode == 0:
print(f" Stopped: {svc}")
else:
print(f" WARNING: Could not stop {svc}: {result.stderr.strip()}")
# Delete lease files
print()
for vlan in vlans:
lf = shared.vlan_leases_file(vlan)
if lf.exists():
lf.unlink()
print(f" Deleted: {lf}")
else:
print(f" No lease file: {lf}")
# Restart
print()
for vlan in vlans:
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
result = subprocess.run(["systemctl", "start", svc],
capture_output=True, text=True)
if result.returncode == 0:
print(f" Started: {svc}")
else:
print(f" WARNING: Could not start {svc}: {result.stderr.strip()}")
print()
print("Done. Devices will get fresh leases on their next DHCP request.")
def show_leases(data):
# Build MAC -> reservation lookup across all VLANs
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
res_by_mac = {}
for r in data.get("dhcp_reservations", []):
if r.get("enabled") is True:
mac = r.get("mac", "").lower().strip()
if mac:
res_by_mac[mac] = (r, vlan_by_name.get(r.get("vlan", ""), {}))
now = int(datetime.now().timestamp())
any_leases = False
for vlan in data["vlans"]:
if validation.is_wg(vlan):
continue
lf = shared.vlan_leases_file(vlan)
if not lf.exists():
continue
lines = lf.read_text().strip().splitlines()
if not lines:
continue
if not any_leases:
print(f"{'IP':<18} {'MAC':<20} {'HOSTNAME':<26} {'VLAN':<8} {'EXPIRES':<22} {'TIME LEFT':<18} {'TYPE':<8} {'DESCRIPTION'}")
print("-" * 145)
any_leases = True
for entry in lines:
parts = entry.split()
if len(parts) < 4:
continue
expire_ts = parts[0]
mac = parts[1]
ip = parts[2]
hostname = parts[3] if parts[3] != "*" else "(unknown)"
hostname = hostname[:26]
if expire_ts == "0":
expires_str = "permanent"
time_left = ""
else:
expire_int = int(expire_ts)
seconds = expire_int - now
if seconds <= 0:
expires_str = "expired"
time_left = ""
else:
hours, rem = divmod(seconds, 3600)
mins, _ = divmod(rem, 60)
expire_dt = datetime.fromtimestamp(expire_int)
expires_str = expire_dt.strftime("%Y-%m-%d %H:%M:%S")
time_left = f"{hours}h {mins}m"
match = res_by_mac.get(mac.lower())
lease_type = "static" if (match and not validation.is_dynamic_ip(match[0])) else "dynamic"
description = match[0].get("description", "") if match else ""
print(f"{ip:<18} {mac:<20} {hostname:<26} {vlan['name']:<8} {expires_str:<22} {time_left:<18} {lease_type:<8} {description}")
if not any_leases:
print("No active leases found.")