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 Failover

+
+
+ +
+

WAN / Uplinks

+ + + + + +
DeviceTypeStateIPv4MetricActions
+

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 += ``; + ap.stations.forEach(s => { + html += ``; + }); + html += `
MACSignalTX rate
${esc(s.mac)}${esc(s.sig || "?")} dBm${esc(s.tx || "?")}
`; + } + 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 += ``; + 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 += `` + + `` + + ``; + }); + html += `
WANPriorityDeviceStatusMetric
${w.active ? "★ " : ""}${esc(w.name)}${prio}${esc(w.device || "—")}${status}${w.route_metric != null ? esc(w.route_metric) : "—"}
`; + 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