- deploy.conf: DNS_RESOLVERS, always 1.1.1.1/8.8.8.8, never a WAN's own DHCP/RA-provided servers (previously whatever Wapana handed out). - ap/99-van-router-dns.conf: global resolved config (fixed DNS, Domains=~., global MulticastDNS=yes — a prerequisite for any per-link mDNS to work at all, not just an on/off toggle). - failover/60-van-wan-dns: NM dispatcher that strips each WAN's DNS/search- domain and disables its mDNS via resolvectl on every connect/lease event (NM's own ipv4/ipv6.ignore-auto-dns can't be set as a config-file default — confirmed rejected as an unknown key — so this enforces it directly instead), retried over ~5s to beat NM's own async DNS commit. Also logs what each WAN advertised, never used, to /run/van-wan-dns/. - ap/21-van-br0.network: MulticastDNS=yes, scoped to the van's own LAN only — .local/mDNS now resolves for ESPHome and other LAN devices without leaking mDNS onto Wapana/Starlink/cellular. - dns/: ZeroTier-managed DNS (zt.wrede.pvt) made reproducible — installed the official zerotier-systemd-manager package (verified against upstream checksums), additive to the above so *.zt.wrede.pvt keeps resolving over the overlay independent of WAN. - ha/esphome.container: ESPHome dashboard as a sibling Podman Quadlet to Home Assistant, same host-network/config-volume pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
84 lines
3.1 KiB
Python
84 lines
3.1 KiB
Python
#!/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()
|