From 8efb8dba5f98417192ecd5c7bd3e4230ed11b6de Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sat, 1 Aug 2026 16:09:01 -0400 Subject: [PATCH] dns: fixed 1.1.1.1/8.8.8.8 lockdown + scoped mDNS, esphome sibling container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ap/21-van-br0.network | 4 ++ ap/99-van-router-dns.conf | 28 +++++++++++++ deploy.conf | 6 +++ deploy.sh | 31 +++++++++++++++ dns/zt-network.local.conf | 4 ++ dns/zt-search.conf | 8 ++++ failover/60-van-wan-dns | 83 +++++++++++++++++++++++++++++++++++++++ ha/esphome.container | 35 +++++++++++++++++ 8 files changed, 199 insertions(+) create mode 100644 ap/99-van-router-dns.conf create mode 100644 dns/zt-network.local.conf create mode 100644 dns/zt-search.conf create mode 100644 failover/60-van-wan-dns create mode 100644 ha/esphome.container diff --git a/ap/21-van-br0.network b/ap/21-van-br0.network index fbff32d..54924d7 100644 --- a/ap/21-van-br0.network +++ b/ap/21-van-br0.network @@ -10,3 +10,7 @@ RequiredForOnline=no [Network] Address=10.42.0.1/24 ConfigureWithoutCarrier=yes +# Resolve .local (mDNS) for devices on the van's own LAN (ESPHome nodes, +# etc.) — scoped to br0 only, not the WAN links, since mDNS is link-local +# and that's the only interface those devices are actually on. +MulticastDNS=yes diff --git a/ap/99-van-router-dns.conf b/ap/99-van-router-dns.conf new file mode 100644 index 0000000..7c42781 --- /dev/null +++ b/ap/99-van-router-dns.conf @@ -0,0 +1,28 @@ +# Fixed upstream resolvers for this router — see deploy.conf's DNS_RESOLVERS. +# Never the WAN-provided ones: the failover/60-van-wan-dns NM dispatcher +# strips each WAN's DHCP/RA-provided DNS from resolved as soon as it appears +# (NM's own ipv4/ipv6.ignore-auto-dns can't be set as a config-file default — +# it's rejected as an unknown key there, even though it's a real per- +# connection property), so these are the only unicast resolvers in play, +# regardless of whether the WAN is Wapana, Starlink, or cellular. +# Domains=~. makes them the default route for every query (there being no +# competing per-link DNS to prioritize over them in the first place). +# +# This does NOT affect .local (mDNS) resolution — that's handled separately, +# per-link, only on br0 (see 21-van-br0.network's MulticastDNS=yes), so +# ESPHome/other mDNS devices on the van's own LAN still resolve. +# +# ZeroTier's own DNS (zt.wrede.pvt, via zerotier-systemd-manager) is a +# separate, more-specific routing domain on the ztuga7c2kh link and is +# unaffected by this — resolved always prefers a domain-specific route over +# the Domains=~. fallback. +[Resolve] +DNS=@DNS_RESOLVERS@ +Domains=~. +# Global default: resolved gates per-link MulticastDNS=yes settings behind +# this — a link can't enable mDNS on its own if the global default is "no" +# (confirmed: "Setting mDNS support level yes for X, but the global support +# level is no"). br0 (21-van-br0.network) opts in; every WAN link is opted +# back out explicitly by failover/60-van-wan-dns so mDNS stays scoped to the +# van's own LAN and never leaks onto Wapana/Starlink/cellular. +MulticastDNS=yes diff --git a/deploy.conf b/deploy.conf index f9d2968..68a8673 100644 --- a/deploy.conf +++ b/deploy.conf @@ -24,3 +24,9 @@ STARLINK_IFACE=enxd8ec5eeb3512 # Cellular modem USB vendor ID (Quectel EC25-AF) MODEM_USB_VENDOR=2c7c + +# Fixed upstream DNS resolvers — this router always uses these, never a WAN's +# own DHCP/RA-provided servers (NetworkManager is told to ignore those +# entirely; see ap/van-wan-dns.conf + ap/99-van-router-dns.conf). Keeps +# resolution identical on Wapana, Starlink, or cellular. +DNS_RESOLVERS="1.1.1.1 8.8.8.8" diff --git a/deploy.sh b/deploy.sh index 4e6f4a5..d9f7d9c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -22,6 +22,7 @@ render() { # render -> stdout, with @TOKEN@ placeholders substituted -e "s|@LAN_USB_IFACE@|$LAN_USB_IFACE|g" \ -e "s|@STARLINK_IFACE@|$STARLINK_IFACE|g" \ -e "s|@MODEM_USB_VENDOR@|$MODEM_USB_VENDOR|g" \ + -e "s|@DNS_RESOLVERS@|$DNS_RESOLVERS|g" \ "$1" } install_rendered() { # install_rendered [mode] @@ -104,6 +105,26 @@ install -D -m0644 ap/regdomain.service /etc/systemd/system/regdomain.servic install -D -m0644 ap/rtw89.conf /etc/modprobe.d/rtw89.conf install -D -m0644 ap/99-van-router.conf /etc/sysctl.d/99-van-router.conf +echo "== dns ==" +# Fixed upstream resolvers (deploy.conf's DNS_RESOLVERS), never a WAN's own +# DHCP/RA-provided DNS — see the files themselves for the full rationale. +# (NM's ipv4/ipv6.ignore-auto-dns can't be set as a config-file connection +# default — NM rejects it there — so 60-van-wan-dns enforces this directly +# against resolved instead, on every WAN connect/lease event.) +# .local (mDNS) is handled separately, scoped to br0 (see 21-van-br0.network +# above). ZeroTier-managed DNS (zt.wrede.pvt) is a separate, additive path. +install_rendered ap/99-van-router-dns.conf /etc/systemd/resolved.conf.d/99-van-router-dns.conf +install -D -m0755 failover/60-van-wan-dns /etc/NetworkManager/dispatcher.d/60-van-wan-dns +# ZeroTier-managed DNS for zt.wrede.pvt (see README's "ZeroTier managed DNS"). +# allowDNS on the network + the search-domain drop-in are repo-tracked so a +# reimage doesn't need the manual `zerotier-cli set ... allowDNS=1` step +# remembered by hand; the manager binary itself is a hand-installed .deb +# (not in apt) — see https://github.com/zerotier/zerotier-systemd-manager/releases. +install -D -m0600 dns/zt-network.local.conf /var/lib/zerotier-one/networks.d/d3ecf5726d041b2a.local.conf +install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf +dpkg -s zerotier-systemd-manager >/dev/null 2>&1 \ + || warn "zerotier-systemd-manager not installed — zt.wrede.pvt won't resolve. Install the arm64 .deb from https://github.com/zerotier/zerotier-systemd-manager/releases" + echo "== failover ==" install -D -m0755 failover/van-failover /usr/local/sbin/van-failover install -D -m0644 failover/config.json /etc/van-failover/config.json @@ -177,6 +198,10 @@ echo "== home assistant ==" # deploy never bounces HA — after editing the .container, restart it manually. install -D -m0644 ha/homeassistant.container /etc/containers/systemd/homeassistant.container install -d -m0755 /srv/homeassistant +# ESPHome dashboard (sibling container, same rationale as HA above — no +# Supervisor/add-on store here). +install -D -m0644 ha/esphome.container /etc/containers/systemd/esphome.container +install -d -m0755 /srv/esphome echo "== hardware watchdog ==" install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf @@ -187,6 +212,11 @@ systemctl daemon-reload # Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload # alone does NOT re-arm RuntimeWatchdogSec). Safe online. systemctl daemon-reexec +# Safe online: picks up 99-van-router-dns.conf immediately. NM's +# ignore-auto-dns only takes effect on a connection's next activation though — +# an already-up WAN keeps its currently-applied DNS until it reconnects (or +# reboot), deliberately not forced here (reconnecting a WAN flaps it). +systemctl restart systemd-resolved # networkd here owns only the AP radios + bridge + wired LAN port (no real uplink); # its wait-online would just stall network-online.target. NM-wait-online covers WANs. systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true @@ -195,6 +225,7 @@ systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables sy # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" +systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)" systemctl restart van-thermal systemctl restart van-gps-owntracks # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. diff --git a/dns/zt-network.local.conf b/dns/zt-network.local.conf new file mode 100644 index 0000000..ed63c70 --- /dev/null +++ b/dns/zt-network.local.conf @@ -0,0 +1,4 @@ +allowManaged=1 +allowGlobal=0 +allowDefault=0 +allowDNS=1 diff --git a/dns/zt-search.conf b/dns/zt-search.conf new file mode 100644 index 0000000..bfe0bfc --- /dev/null +++ b/dns/zt-search.conf @@ -0,0 +1,8 @@ +# vim: ft=systemd +# Adds zt.wrede.pvt as a search domain (bare-hostname completion, e.g. +# `ssh rosepark` -> rosepark.zt.wrede.pvt) alongside the routing-only +# ~zt.wrede.pvt entry that zerotier-systemd-manager writes into +# 99-ztuga7c2kh.network itself (that file is fully manager-owned — don't +# edit it directly, this .network.d drop-in layers on top instead). +[Network] +Domains=zt.wrede.pvt diff --git a/failover/60-van-wan-dns b/failover/60-van-wan-dns new file mode 100644 index 0000000..6fef324 --- /dev/null +++ b/failover/60-van-wan-dns @@ -0,0 +1,83 @@ +#!/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() diff --git a/ha/esphome.container b/ha/esphome.container new file mode 100644 index 0000000..0b02341 --- /dev/null +++ b/ha/esphome.container @@ -0,0 +1,35 @@ +# ESPHome dashboard as a Podman Quadlet, same pattern as homeassistant.container +# (this isn't Home Assistant Supervised, so there's no add-on store — a sibling +# container is the equivalent). Installed by deploy.sh to /etc/containers/systemd/; +# systemd generates esphome.service from it. +# +# Host networking: the dashboard binds :6052 directly (http://10.42.0.1:6052), +# and ESPHome's mDNS-based OTA discovery/flashing of already-provisioned nodes +# needs to see the LAN as the host does — a bridged network would need explicit +# port/mDNS forwarding for the same result. +[Unit] +Description=ESPHome dashboard (Podman container) +Wants=network-online.target +After=network-online.target + +[Container] +Image=ghcr.io/esphome/esphome:stable +ContainerName=esphome +Network=host +Volume=/srv/esphome:/config +Environment=TZ=America/Toronto +# Initial flash of a new device needs USB serial access; OTA re-flashes of an +# already-provisioned node don't. Uncomment and set the real path to flash +# over USB (find it with `ls /dev/ttyUSB* /dev/ttyACM*` after plugging the +# device in): +#Volume=/dev/ttyUSB0:/dev/ttyUSB0 +#AddCapability=SYS_ADMIN + +[Service] +Restart=always +# First start pulls the image; PlatformIO also downloads toolchains on a +# node's first compile — both want a working WAN, not just a fast one. +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target