Files
vanlink/failover/60-van-wan-dns
T
Andreas WredeandClaude Sonnet 5 8efb8dba5f dns: fixed 1.1.1.1/8.8.8.8 lockdown + scoped mDNS, esphome sibling container
- 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>
2026-08-01 16:09:01 -04:00

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()