diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fac8d20
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.claude/*
diff --git a/ap/10-van-ap.network b/ap/10-van-ap.network
new file mode 100644
index 0000000..eabb9c2
--- /dev/null
+++ b/ap/10-van-ap.network
@@ -0,0 +1,9 @@
+[Match]
+Name=wlxc83a35a4ee55
+
+[Link]
+RequiredForOnline=no
+
+[Network]
+Address=10.42.0.1/24
+ConfigureWithoutCarrier=yes
diff --git a/ap/99-van-router.conf b/ap/99-van-router.conf
new file mode 100644
index 0000000..119d730
--- /dev/null
+++ b/ap/99-van-router.conf
@@ -0,0 +1 @@
+net.ipv4.ip_forward=1
diff --git a/ap/default-hostapd b/ap/default-hostapd
new file mode 100644
index 0000000..b63c3ae
--- /dev/null
+++ b/ap/default-hostapd
@@ -0,0 +1 @@
+DAEMON_CONF="/etc/hostapd/hostapd.conf"
diff --git a/ap/hostapd.conf b/ap/hostapd.conf
new file mode 100644
index 0000000..c8b9fdd
--- /dev/null
+++ b/ap/hostapd.conf
@@ -0,0 +1,23 @@
+interface=wlxc83a35a4ee55
+driver=nl80211
+ssid=VanLink
+country_code=CA
+ieee80211d=1
+ieee80211h=0
+hw_mode=a
+channel=149
+wmm_enabled=1
+ieee80211n=1
+ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
+ieee80211ac=1
+vht_oper_chwidth=1
+vht_oper_centr_freq_seg0_idx=155
+vht_capab=[RXLDPC][SHORT-GI-80][SU-BEAMFORMEE]
+ieee80211ax=1
+he_oper_chwidth=1
+he_oper_centr_freq_seg0_idx=155
+auth_algs=1
+wpa=2
+wpa_passphrase=1foot11foot11
+wpa_key_mgmt=WPA-PSK
+rsn_pairwise=CCMP
diff --git a/ap/nftables.conf b/ap/nftables.conf
new file mode 100755
index 0000000..a41bb89
--- /dev/null
+++ b/ap/nftables.conf
@@ -0,0 +1,11 @@
+#!/usr/sbin/nft -f
+# van-router NAT — masquerade LAN out whatever the WAN of the moment is
+# (anything that is NOT the AP interface: ethernet/Starlink, wifi, future 4G)
+table ip van_router_nat
+delete table ip van_router_nat
+table ip van_router_nat {
+ chain postrouting {
+ type nat hook postrouting priority srcnat; policy accept;
+ ip saddr 10.42.0.0/24 oifname != "wlxc83a35a4ee55" masquerade
+ }
+}
diff --git a/ap/regdomain.service b/ap/regdomain.service
new file mode 100644
index 0000000..f9de904
--- /dev/null
+++ b/ap/regdomain.service
@@ -0,0 +1,9 @@
+[Unit]
+Description=Set wireless regulatory domain
+After=network-pre.target
+Before=NetworkManager.service
+[Service]
+Type=oneshot
+ExecStart=/usr/sbin/iw reg set CA
+[Install]
+WantedBy=multi-user.target
diff --git a/ap/rtw89.conf b/ap/rtw89.conf
new file mode 100644
index 0000000..05d1c2c
--- /dev/null
+++ b/ap/rtw89.conf
@@ -0,0 +1 @@
+options rtw89_core disable_ps_mode=Y
diff --git a/ap/van-ap-dnsmasq.conf b/ap/van-ap-dnsmasq.conf
new file mode 100644
index 0000000..684d135
--- /dev/null
+++ b/ap/van-ap-dnsmasq.conf
@@ -0,0 +1,9 @@
+interface=wlxc83a35a4ee55
+bind-dynamic
+except-interface=lo
+domain-needed
+bogus-priv
+dhcp-authoritative
+dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
+dhcp-option=option:router,10.42.0.1
+dhcp-option=option:dns-server,10.42.0.1
diff --git a/ap/van-ap-dnsmasq.service b/ap/van-ap-dnsmasq.service
new file mode 100644
index 0000000..86345ab
--- /dev/null
+++ b/ap/van-ap-dnsmasq.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=dnsmasq DHCP+DNS for van-ap AP
+After=hostapd.service
+Wants=hostapd.service
+[Service]
+ExecStartPre=/usr/sbin/dnsmasq --test --conf-file=/etc/van-ap/dnsmasq.conf
+ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/van-ap/dnsmasq.conf
+Restart=on-failure
+RestartSec=2
+[Install]
+WantedBy=multi-user.target
diff --git a/ap/van-ap-unmanaged.conf b/ap/van-ap-unmanaged.conf
new file mode 100644
index 0000000..ed8ef8b
--- /dev/null
+++ b/ap/van-ap-unmanaged.conf
@@ -0,0 +1,2 @@
+[keyfile]
+unmanaged-devices=interface-name:wlxc83a35a4ee55
diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html
new file mode 100644
index 0000000..cc395f9
--- /dev/null
+++ b/cockpit/vanrouter/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+ Van Router
+
+
+
+
+
+
+
+
+
Van Router
+ loading…
+
+
+
+
+
Access Point
+
+
+
+
+
+
+
+
+
WAN / Uplinks
+
+
+ | Device | Type | State | IPv4 | Metric | Actions |
+
+
+
+
Lower metric = preferred default route. ★ blue = current internet egress.
+
+
+
+
+
+
diff --git a/cockpit/vanrouter/manifest.json b/cockpit/vanrouter/manifest.json
new file mode 100644
index 0000000..14a9a9a
--- /dev/null
+++ b/cockpit/vanrouter/manifest.json
@@ -0,0 +1,9 @@
+{
+ "version": 0,
+ "menu": {
+ "index": {
+ "label": "Van Router",
+ "order": 70
+ }
+ }
+}
diff --git a/cockpit/vanrouter/vanrouter.css b/cockpit/vanrouter/vanrouter.css
new file mode 100644
index 0000000..feb8452
--- /dev/null
+++ b/cockpit/vanrouter/vanrouter.css
@@ -0,0 +1,18 @@
+.vr-wrap { padding: 16px; max-width: 920px; }
+.vr-head { display: flex; align-items: baseline; gap: 14px; }
+.vr-head h2 { margin: 0; }
+.card { background: #fff; border: 1px solid #d2d2d2; border-radius: 6px;
+ padding: 16px; margin-top: 16px; }
+.card-head { display: flex; align-items: center; justify-content: space-between; }
+.card h3 { margin: 0 0 10px 0; }
+.card-head h3 { margin: 0; }
+table { border-collapse: collapse; width: 100%; margin-top: 6px; }
+th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #ededed; font-size: 14px; }
+th { color: #6a6e73; font-weight: 600; }
+.pill { padding: 2px 8px; border-radius: 10px; font-size: 12px; white-space: nowrap; }
+.ok { background: #bde5b8; color: #1e4f18; }
+.bad { background: #f0b8b8; color: #5f1414; }
+.muted { color: #6a6e73; font-size: 12px; }
+.active-wan { font-weight: 700; color: #0066cc; }
+.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
+.btn[disabled] { cursor: default; opacity: 0.5; }
diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js
new file mode 100644
index 0000000..f19675c
--- /dev/null
+++ b/cockpit/vanrouter/vanrouter.js
@@ -0,0 +1,209 @@
+"use strict";
+
+// Basic Van Router dashboard for Cockpit.
+// Read-only status via cockpit.spawn (logged-in user); mutating actions use
+// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation.
+
+const AP = "wlxc83a35a4ee55"; // Realtek RTL8852BU AP interface (MAC-derived, stable)
+const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
+
+function run(args, opts) {
+ return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
+}
+function sh(cmd, opts) {
+ return run(["sh", "-c", cmd], opts);
+}
+function esc(s) {
+ return String(s).replace(/[&<>"]/g, c =>
+ ({ "&": "&", "<": "<", ">": ">", "\"": """ }[c]));
+}
+// Single-quote a string for safe use inside `sh -c`.
+function esc_sh(s) {
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
+}
+
+/* ---------- Access Point ---------- */
+
+async function readAP() {
+ let active = "inactive";
+ try { active = (await sh("systemctl is-active hostapd || true")).trim(); } catch (e) { /* ignore */ }
+
+ let info = "";
+ try { info = await run(["iw", "dev", AP, "info"]); } catch (e) { info = ""; }
+ const ssid = (info.match(/\bssid (.+)/) || [])[1];
+ const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
+ const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
+
+ let stations = [];
+ try {
+ const dump = await run(["iw", "dev", AP, "station", "dump"]);
+ stations = dump.split(/Station /).slice(1).map(b => ({
+ mac: b.split(" ")[0],
+ sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
+ tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1]
+ }));
+ } catch (e) { stations = []; }
+
+ return { active, ssid, chan, width, stations };
+}
+
+function renderAP(ap) {
+ const beaconing = !!ap.ssid;
+ let html = `hostapd: ${esc(ap.active)}`;
+ if (beaconing)
+ html += ` SSID ${esc(ap.ssid)} ${esc(ap.chan || "")} ${esc(ap.width || "")}`;
+ else
+ html += ` not beaconing`;
+ html += `
Clients: ${ap.stations.length}
`;
+
+ if (ap.stations.length) {
+ html += `| MAC | Signal | TX rate |
`;
+ ap.stations.forEach(s => {
+ html += `| ${esc(s.mac)} | ${esc(s.sig || "?")} dBm | ${esc(s.tx || "?")} |
`;
+ });
+ html += `
`;
+ }
+ document.getElementById("ap").innerHTML = html;
+}
+
+/* ---------- WAN failover (van-failover daemon state) ---------- */
+
+async function readFailover() {
+ try {
+ return JSON.parse(await run(["cat", "/run/van-failover/state.json"]));
+ } catch (e) {
+ return null;
+ }
+}
+
+function renderFailover(fo) {
+ const el = document.getElementById("failover");
+ if (!fo || !fo.wans) {
+ el.innerHTML = `van-failover daemon not running (no state file).
`;
+ return;
+ }
+ let html = `Active egress: ${esc(fo.active_device || "—")}` +
+ ` · updated ${esc(fo.updated || "")}
`;
+ html += `| WAN | Priority | Device | Status | Metric |
`;
+ fo.wans.forEach(w => {
+ const status = !w.present ? `absent`
+ : w.up ? `up`
+ : `down`;
+ const prio = w.preferred ? `${esc(w.priority)} (preferred)` : esc(w.priority);
+ html += `| ${w.active ? "★ " : ""}${esc(w.name)} | ` +
+ `${prio} | ${esc(w.device || "—")} | ` +
+ `${status} | ${w.route_metric != null ? esc(w.route_metric) : "—"} |
`;
+ });
+ html += `
`;
+ el.innerHTML = html;
+}
+
+/* ---------- WAN / uplinks ---------- */
+
+async function readWAN() {
+ const devOut = await run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]);
+ const devs = devOut.trim().split("\n").map(line => {
+ const [device, type, state, ...rest] = line.split(":");
+ return { device, type, state, connection: rest.join(":") };
+ }).filter(d => (d.type === "ethernet" || d.type === "wifi") && d.device !== AP);
+
+ let routes = [];
+ try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; }
+ const metricByDev = {};
+ routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
+ const activeDev = routes.length
+ ? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
+ : null;
+
+ let addrs = [];
+ try { addrs = JSON.parse(await run(["ip", "-j", "-4", "addr"])); } catch (e) { addrs = []; }
+ const ipByDev = {};
+ addrs.forEach(a => {
+ const info = (a.addr_info || []).find(x => x.family === "inet");
+ if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
+ });
+
+ devs.forEach(d => {
+ d.metric = metricByDev[d.device];
+ d.ip = ipByDev[d.device];
+ d.active = d.device === activeDev;
+ });
+ return devs;
+}
+
+function renderWAN(devs) {
+ const tb = document.getElementById("wan");
+ tb.innerHTML = "";
+ devs.forEach(d => {
+ const isUp = d.state === "connected";
+ const tr = document.createElement("tr");
+ tr.innerHTML =
+ `${d.active ? "★ " : ""}${esc(d.device)} | ` +
+ `${esc(d.type)} | ` +
+ `${esc(d.state)} | ` +
+ `${esc(d.ip || "—")} | ` +
+ `${d.metric != null ? esc(d.metric) : "—"} | ` +
+ ` | `;
+ const acts = tr.querySelector(".acts");
+
+ if (d.connection) {
+ const pref = document.createElement("button");
+ pref.className = "btn";
+ pref.textContent = "Prefer";
+ pref.disabled = d.active;
+ pref.onclick = () => preferWAN(d);
+ acts.appendChild(pref);
+ }
+ const tog = document.createElement("button");
+ tog.className = "btn";
+ tog.textContent = isUp ? "Down" : "Up";
+ tog.onclick = () => toggleWAN(d, isUp);
+ acts.appendChild(tog);
+
+ tb.appendChild(tr);
+ });
+}
+
+async function preferWAN(chosen) {
+ // Don't touch metrics directly — van-failover owns them and would revert us within
+ // one probe cycle (and `nmcli device reapply` flaps the r8152 USB carrier). Instead
+ // record the preference; the daemon gives this device the lowest base metric.
+ try {
+ await sh(`mkdir -p ${esc_sh(PREFER_FILE.replace(/\/[^/]*$/, ""))} && ` +
+ `printf %s ${esc_sh(chosen.device)} > ${esc_sh(PREFER_FILE)}`,
+ { superuser: "require" });
+ } catch (e) { window.alert("Prefer failed: " + e.message); }
+ setTimeout(refresh, 4500); // daemon enforces on its next probe loop
+}
+
+async function toggleWAN(d, isUp) {
+ try {
+ const verb = isUp ? "disconnect" : "connect";
+ await run(["nmcli", "device", verb, d.device], { superuser: "require" });
+ } catch (e) { window.alert("Toggle failed: " + e.message); }
+ refresh();
+}
+
+async function restartAP() {
+ try { await run(["systemctl", "restart", "hostapd"], { superuser: "require" }); }
+ catch (e) { window.alert("Restart failed: " + e.message); }
+ setTimeout(refresh, 2500);
+}
+
+/* ---------- loop ---------- */
+
+async function refresh() {
+ try {
+ const [ap, fo, wan] = await Promise.all([readAP(), readFailover(), readWAN()]);
+ renderAP(ap);
+ renderFailover(fo);
+ renderWAN(wan);
+ document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
+ } catch (e) {
+ document.getElementById("updated").textContent = "error: " + (e.message || e);
+ }
+}
+
+document.getElementById("ap-restart").onclick = restartAP;
+refresh();
+setInterval(refresh, 5000);
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..01528db
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Deploy vanlink configs/scripts from this directory to their system locations.
+# Usage: cd ~/vanlink && sudo ./deploy.sh
+# Idempotent. See README.md §4 for the two manual steps this does NOT do
+# (zerotier-systemd-manager binary install, hostapd unmask).
+set -euo pipefail
+cd "$(dirname "$(readlink -f "$0")")"
+[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
+
+echo "== access point =="
+install -D -m0644 ap/hostapd.conf /etc/hostapd/hostapd.conf
+install -D -m0644 ap/default-hostapd /etc/default/hostapd
+install -D -m0644 ap/van-ap-dnsmasq.conf /etc/van-ap/dnsmasq.conf
+install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
+install -D -m0644 ap/10-van-ap.network /etc/systemd/network/10-van-ap.network
+install -D -m0644 ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf
+install -D -m0644 ap/nftables.conf /etc/nftables.conf
+install -D -m0644 ap/regdomain.service /etc/systemd/system/regdomain.service
+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 "== failover =="
+install -D -m0755 failover/van-failover /usr/local/sbin/van-failover
+install -D -m0644 failover/config.json /etc/van-failover/config.json
+install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover.service
+install -D -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee
+install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
+
+echo "== zerotier managed dns =="
+install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
+
+echo "== cockpit plugin =="
+install -d /usr/share/cockpit/vanrouter
+install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
+
+echo "== apply =="
+sysctl --system >/dev/null
+systemctl daemon-reload
+systemctl unmask hostapd >/dev/null 2>&1 || true
+systemctl enable regdomain.service hostapd van-ap-dnsmasq nftables systemd-networkd van-failover >/dev/null 2>&1 || true
+# restart in dependency order; AP iface IP first, then hostapd/dnsmasq, then NAT/failover
+systemctl restart systemd-networkd
+systemctl restart hostapd van-ap-dnsmasq nftables van-failover
+networkctl reload 2>/dev/null || true
+
+echo
+echo "Deployed. Verify:"
+echo " iw dev wlxc83a35a4ee55 info | grep -E 'ssid|channel|width'"
+echo " cat /run/van-failover/state.json"
+echo "Manual one-time steps (see README §4): zerotier-systemd-manager binary + 'zerotier-cli set allowDNS=1'."
diff --git a/dns/99-ztuga7c2kh.network.generated b/dns/99-ztuga7c2kh.network.generated
new file mode 100644
index 0000000..1bcc0e5
--- /dev/null
+++ b/dns/99-ztuga7c2kh.network.generated
@@ -0,0 +1,13 @@
+# vim: ft=systemd
+# --- Managed by zerotier-systemd-manager. Do not remove this comment. ---
+[Match]
+Name=ztuga7c2kh
+
+[Network]
+Description=suspicious_house
+DHCP=no
+DNS=192.168.196.115
+DNS=192.168.10.5
+Domains=~wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
+ConfigureWithoutCarrier=true
+KeepConfiguration=static
diff --git a/dns/zerotier-systemd-manager.service b/dns/zerotier-systemd-manager.service
new file mode 100644
index 0000000..3f8ed54
--- /dev/null
+++ b/dns/zerotier-systemd-manager.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=Update zerotier per-interface DNS settings
+Requires=zerotier-one.service
+After=zerotier-one.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/zerotier-systemd-manager
diff --git a/dns/zerotier-systemd-manager.timer b/dns/zerotier-systemd-manager.timer
new file mode 100644
index 0000000..0014869
--- /dev/null
+++ b/dns/zerotier-systemd-manager.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=Update zerotier per-interface DNS settings
+
+[Timer]
+OnStartupSec=1min
+OnUnitInactiveSec=1min
+
+[Install]
+WantedBy=timers.target
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..567688f
--- /dev/null
+++ b/dns/zt-search.conf
@@ -0,0 +1,3 @@
+[Network]
+Domains=
+Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
diff --git a/failover/50-disable-eee b/failover/50-disable-eee
new file mode 100755
index 0000000..90848ad
--- /dev/null
+++ b/failover/50-disable-eee
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Disable EEE on USB ethernet (r8152): its idle power-save parks the backup WAN link
+# and breaks van-failover health probes. See wayback-campervan-ap memory.
+iface="$1"; action="$2"
+[ "$action" = "up" ] || exit 0
+case "$iface" in
+ en*|eth*) /usr/sbin/ethtool --set-eee "$iface" eee off >/dev/null 2>&1 || true ;;
+esac
diff --git a/failover/99-van-arp.conf b/failover/99-van-arp.conf
new file mode 100644
index 0000000..eab6b23
--- /dev/null
+++ b/failover/99-van-arp.conf
@@ -0,0 +1,4 @@
+# Multi-WAN on possibly-shared subnets: answer ARP only for the incoming iface,
+# announce the best local source — avoids ARP flux between eth/wifi WANs.
+net.ipv4.conf.all.arp_ignore=1
+net.ipv4.conf.all.arp_announce=2
diff --git a/failover/__pycache__/van-failovercpython-312.pyc b/failover/__pycache__/van-failovercpython-312.pyc
new file mode 100644
index 0000000..b59dfc2
Binary files /dev/null and b/failover/__pycache__/van-failovercpython-312.pyc differ
diff --git a/failover/config.json b/failover/config.json
new file mode 100644
index 0000000..1fcafbb
--- /dev/null
+++ b/failover/config.json
@@ -0,0 +1,16 @@
+{
+ "probe_interval": 4,
+ "probe_timeout": 3,
+ "fail_threshold": 3,
+ "ok_threshold": 2,
+ "probe_urls": [
+ "http://connectivity-check.ubuntu.com/",
+ "http://www.gstatic.com/generate_204",
+ "http://cp.cloudflare.com/"
+ ],
+ "wans": [
+ { "name": "wifi", "device": "wlp1s0", "metric": 100 },
+ { "name": "starlink", "device": "enxd8ec5eeb3512", "metric": 200 },
+ { "name": "cellular", "connection": "Koodo", "metric": 300 }
+ ]
+}
diff --git a/failover/van-failover b/failover/van-failover
new file mode 100755
index 0000000..8945a84
--- /dev/null
+++ b/failover/van-failover
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""van-failover — multi-WAN health-probe + metric-based failover for the campervan router.
+
+Each WAN gets a base route-metric defining priority (lower = preferred). The daemon
+probes each WAN's *real* internet reachability independently (HTTP 204 bound to the
+interface, so it catches "link up but no internet" AND captive portals), and demotes
+a failed WAN by raising its NM route-metric so the kernel routes via the next-best
+healthy WAN. Auto fail-back on recovery, with hysteresis. Writes /run/van-failover/state.json
+for the Cockpit dashboard. NAT (nftables masquerade oifname != AP) already follows
+whatever the active default route is, so nothing else is needed.
+"""
+import json
+import os
+import subprocess
+import tempfile
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+CONFIG = "/etc/van-failover/config.json"
+STATE = "/run/van-failover/state.json"
+PREFER = "/run/van-failover/prefer" # optional: a device name the Cockpit UI asks us to prefer
+PENALTY = 10000 # added to a WAN's metric while it is unhealthy
+PREFER_METRIC = 50 # base metric given to the user-preferred WAN (below every config metric)
+
+
+def sh(args, timeout=10):
+ try:
+ return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
+ except (subprocess.TimeoutExpired, OSError):
+ return None
+
+
+def active_connections():
+ """List of {name, device, type} for currently-active NM connections."""
+ r = sh(["nmcli", "-t", "-f", "NAME,DEVICE,TYPE", "connection", "show", "--active"])
+ out = []
+ if r and r.returncode == 0:
+ for line in r.stdout.splitlines():
+ # nmcli -t escapes ':' inside fields as '\:'; split on unescaped ':'
+ parts = _split_nmcli(line)
+ if len(parts) >= 3:
+ out.append({"name": parts[0], "device": parts[1], "type": parts[2]})
+ return out
+
+
+def _split_nmcli(line):
+ parts, cur, esc = [], "", False
+ for ch in line:
+ if esc:
+ cur += ch
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == ":":
+ parts.append(cur)
+ cur = ""
+ else:
+ cur += ch
+ parts.append(cur)
+ return parts
+
+
+def resolve(wan, actives):
+ """Return (device, connection) for a WAN, using whichever identifier is configured."""
+ if wan.get("device"):
+ dev = wan["device"]
+ conn = next((a["name"] for a in actives if a["device"] == dev), None)
+ return dev, conn
+ conn = wan.get("connection")
+ dev = next((a["device"] for a in actives if a["name"] == conn), None)
+ return dev, conn
+
+
+def probe_one(dev, url, timeout):
+ """True if URL returns HTTP 204 out the bound device (if! forces SO_BINDTODEVICE)."""
+ r = sh(["curl", "-s", "-m", str(timeout), "--interface", "if!" + dev,
+ "-o", "/dev/null", "-w", "%{http_code}", url], timeout=timeout + 2)
+ return bool(r and r.stdout.strip() == "204")
+
+
+def default_routes():
+ """dict device -> metric for current default routes."""
+ r = sh(["ip", "-j", "route", "show", "default"])
+ out = {}
+ if r and r.stdout.strip():
+ try:
+ for rt in json.loads(r.stdout):
+ if rt.get("dev"):
+ out[rt["dev"]] = rt.get("metric", 0)
+ except json.JSONDecodeError:
+ pass
+ return out
+
+
+def routes_on(dev):
+ """List of {metric, gw} for default routes currently on a device."""
+ r = sh(["ip", "-j", "route", "show", "default", "dev", dev])
+ out = []
+ if r and r.stdout.strip():
+ try:
+ for rt in json.loads(r.stdout):
+ out.append({"metric": rt.get("metric", 0), "gw": rt.get("gateway")})
+ except json.JSONDecodeError:
+ pass
+ return out
+
+
+def enforce_route(dev, metric):
+ """Ensure exactly one default route on dev at the desired metric, via `ip route`.
+ NEVER use `nmcli device reapply` — it resets r8152 USB-ethernet carriers and causes
+ a failover flap. This is a pure routing change (carrier-safe, verified)."""
+ if not dev:
+ return
+ rts = routes_on(dev)
+ if any(r["metric"] == metric for r in rts):
+ # desired metric already present; just prune any stale others
+ for r in rts:
+ if r["metric"] != metric and r["gw"]:
+ sh(["ip", "route", "del", "default", "via", r["gw"], "dev", dev, "metric", str(r["metric"])])
+ return
+ # Prefer the gw from an existing default route; fall back to NM's known gateway so we can
+ # also *restore* a route that went missing while the carrier is still up (not just rebase one).
+ gw = next((r["gw"] for r in rts if r["gw"]), None) or device_gateway(dev)
+ if not gw:
+ return # no gateway known (carrier down); profile metric still set
+ r = sh(["ip", "route", "add", "default", "via", gw, "dev", dev, "metric", str(metric), "proto", "static"])
+ if not (r and r.returncode == 0):
+ # Add failed — most likely another dev transiently holds this exact metric during a
+ # preference swap. Leave the existing route intact and retry next loop; do NOT prune,
+ # or we'd strand this dev with no default route at all.
+ return
+ for old in rts:
+ if old["metric"] != metric and old["gw"]:
+ sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])])
+
+
+def device_gateway(dev):
+ """NM's gateway for a device — available even when its default route is missing."""
+ r = sh(["nmcli", "-g", "IP4.GATEWAY", "device", "show", dev])
+ if r and r.returncode == 0:
+ return r.stdout.strip() or None
+ return None
+
+
+def set_profile_metric(conn, metric):
+ """Update the NM profile's route-metric (no reapply) so NM re-assertions stay consistent."""
+ if conn:
+ sh(["nmcli", "connection", "modify", conn, "ipv4.route-metric", str(metric)])
+
+
+def read_prefer():
+ """Device name the UI wants preferred, or None. Ephemeral (cleared on reboot)."""
+ try:
+ with open(PREFER) as f:
+ return f.read().strip() or None
+ except OSError:
+ return None
+
+
+def write_state(state):
+ os.makedirs(os.path.dirname(STATE), exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(STATE))
+ with os.fdopen(fd, "w") as f:
+ json.dump(state, f, indent=2)
+ os.chmod(tmp, 0o644)
+ os.replace(tmp, STATE)
+
+
+def main():
+ with open(CONFIG) as f:
+ cfg = json.load(f)
+ interval = cfg.get("probe_interval", 5)
+ fail_th = cfg.get("fail_threshold", 3)
+ ok_th = cfg.get("ok_threshold", 2)
+ ptimeout = cfg.get("probe_timeout", 4)
+ urls = cfg["probe_urls"]
+ wans = cfg["wans"]
+
+ # Optimistic start: assume up so healthy WANs immediately get their base metric.
+ rt = {w["name"]: {"up": True, "ok": ok_th, "fail": 0, "applied": None} for w in wans}
+ print(f"van-failover started: {[w['name'] for w in wans]}", flush=True)
+
+ pool = ThreadPoolExecutor(max_workers=max(4, len(wans) * len(urls)))
+ while True:
+ actives = active_connections()
+ resolved = {w["name"]: resolve(w, actives) for w in wans}
+ prefer_dev = read_prefer()
+ # A WAN is "present" (probeable/manageable) only with both an active device and connection.
+ present = {w["name"]: bool(resolved[w["name"]][0]) and bool(resolved[w["name"]][1]) for w in wans}
+
+ # Probe every (present WAN x url) pair concurrently; a WAN is healthy if ANY url
+ # returns 204. Concurrency bounds a failed WAN to ~one timeout, not N serial timeouts.
+ health = {w["name"]: (False if present[w["name"]] else None) for w in wans}
+ tasks = [(w["name"], pool.submit(probe_one, resolved[w["name"]][0], url, ptimeout))
+ for w in wans if present[w["name"]] for url in urls]
+ for name, fut in tasks:
+ if fut.result():
+ health[name] = True
+
+ for w in wans:
+ name = w["name"]
+ dev, conn = resolved[name]
+ # The preferred WAN gets the lowest base so it wins while healthy; the
+ # health PENALTY still applies on top, so failover/fail-back is unchanged.
+ base = PREFER_METRIC if prefer_dev and dev == prefer_dev else w["metric"]
+ s = rt[name]
+ if not present[name]:
+ # Absent (e.g. modem unplugged): don't probe/penalize; just keep the
+ # profile's base metric so it lands at the right priority when it connects.
+ if conn and s["applied"] != base:
+ set_profile_metric(conn, base)
+ s["applied"] = base
+ continue
+ if health[name]:
+ s["ok"] += 1
+ s["fail"] = 0
+ if s["ok"] >= ok_th:
+ s["up"] = True
+ else:
+ s["fail"] += 1
+ s["ok"] = 0
+ if s["fail"] >= fail_th:
+ s["up"] = False
+ desired = base if s["up"] else base + PENALTY
+ # Enforce the live route every loop (carrier-safe, corrects any NM drift);
+ # update the NM profile only on an actual state change.
+ enforce_route(dev, desired)
+ if s["applied"] != desired:
+ print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True)
+ set_profile_metric(conn, desired)
+ s["applied"] = desired
+
+ routes = default_routes()
+ active_dev = min(routes, key=routes.get) if routes else None
+ report = []
+ for w in wans:
+ name = w["name"]
+ dev, conn = resolved[name]
+ report.append({
+ "name": name, "priority": w["metric"], "device": dev, "connection": conn,
+ "present": present[name], "up": rt[name]["up"] if present[name] else False,
+ "healthy": health[name], "preferred": dev is not None and dev == prefer_dev,
+ "route_metric": routes.get(dev), "active": dev is not None and dev == active_dev,
+ })
+ write_state({"updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
+ "active_device": active_dev, "wans": report})
+ time.sleep(interval)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/failover/van-failover.service b/failover/van-failover.service
new file mode 100644
index 0000000..2dfc45b
--- /dev/null
+++ b/failover/van-failover.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Multi-WAN health-probe failover for the campervan router
+After=NetworkManager.service network-online.target
+Wants=NetworkManager.service
+
+[Service]
+Type=simple
+ExecStart=/usr/local/sbin/van-failover
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target