#!/usr/bin/env python3 """NM dispatcher: never let a WAN's DHCP/RA-provided DNS servers get used, and keep mDNS scoped to the van's own LAN (never a WAN). This router only resolves via the fixed servers in /etc/systemd/resolved.conf.d/99-van-router-dns.conf (deploy.conf's DNS_RESOLVERS) — never whatever a WAN happens to hand out (Wapana, a campsite AP, Starlink, cellular). NetworkManager's own ipv4/ipv6.ignore-auto- dns can't be set as a config-file connection default: NM rejects it there ("unknown key") even though it's a real, settable per-connection property — so this enforces the same outcome directly against systemd-resolved instead, on every WAN connect/lease event, for every current and future WAN profile. Also disables mDNS on the WAN link: 99-van-router-dns.conf sets the global MulticastDNS default to "yes" (a prerequisite for br0's own per-link MulticastDNS=yes to mean anything — resolved gates per-link "yes" behind the global default), so every link starts out mDNS-enabled unless told otherwise; this opts each WAN back out as it comes up. Also records what DNS was advertised (but never used) to /run/van-wan-dns/, for reference/debugging. NM dispatcher calling convention: argv = [iface, action]. """ import json import os import subprocess import sys import time STATE_DIR = "/run/van-wan-dns" def clear_link_dns(iface): subprocess.run(["resolvectl", "dns", iface, ""], check=False) subprocess.run(["resolvectl", "domain", iface, ""], check=False) subprocess.run(["resolvectl", "mdns", iface, "no"], check=False) def main(): iface, action = sys.argv[1], sys.argv[2] if action not in ("up", "dhcp4-change", "dhcp6-change"): return # Strip whatever DNS/search-domain this link just got from DHCP/RA — # global DNS=/Domains=~. (99-van-router-dns.conf) then wins for everything. # NM commits its own DNS to resolved asynchronously, shortly *after* this # dispatcher fires — a single clear here loses that race, so retry over a # few seconds (confirmed empirically: one clear immediately gets # clobbered, a clear a couple seconds later sticks). for _ in range(5): clear_link_dns(iface) time.sleep(1) out = subprocess.run(["nmcli", "-t", "-f", "DHCP4.OPTION", "device", "show", iface], capture_output=True, text=True, check=False).stdout servers = domain = None for line in out.splitlines(): if ":" not in line or " = " not in line: continue _, kv = line.split(":", 1) key, _, val = kv.partition(" = ") if key == "domain_name_servers": servers = val.split() elif key == "domain_name": domain = val.strip() or None if not servers: return os.makedirs(STATE_DIR, exist_ok=True) with open(f"{STATE_DIR}/{iface}.json", "w") as f: json.dump({ "iface": iface, "dns_advertised": servers, "domain_advertised": domain, "updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"), }, f, indent=2) if __name__ == "__main__": main()