From cc25dee7e2680ce9e5aa86abad1b58ba84533eec Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sun, 28 Jun 2026 15:09:28 -0400 Subject: [PATCH] initial commit --- .gitignore | 1 + ap/10-van-ap.network | 9 + ap/99-van-router.conf | 1 + ap/default-hostapd | 1 + ap/hostapd.conf | 23 ++ ap/nftables.conf | 11 + ap/regdomain.service | 9 + ap/rtw89.conf | 1 + ap/van-ap-dnsmasq.conf | 9 + ap/van-ap-dnsmasq.service | 11 + ap/van-ap-unmanaged.conf | 2 + cockpit/vanrouter/index.html | 45 ++++ cockpit/vanrouter/manifest.json | 9 + cockpit/vanrouter/vanrouter.css | 18 ++ cockpit/vanrouter/vanrouter.js | 209 +++++++++++++++ deploy.sh | 50 ++++ dns/99-ztuga7c2kh.network.generated | 13 + dns/zerotier-systemd-manager.service | 8 + dns/zerotier-systemd-manager.timer | 9 + dns/zt-network.local.conf | 4 + dns/zt-search.conf | 3 + failover/50-disable-eee | 8 + failover/99-van-arp.conf | 4 + .../__pycache__/van-failovercpython-312.pyc | Bin 0 -> 13384 bytes failover/config.json | 16 ++ failover/van-failover | 251 ++++++++++++++++++ failover/van-failover.service | 13 + 27 files changed, 738 insertions(+) create mode 100644 .gitignore create mode 100644 ap/10-van-ap.network create mode 100644 ap/99-van-router.conf create mode 100644 ap/default-hostapd create mode 100644 ap/hostapd.conf create mode 100755 ap/nftables.conf create mode 100644 ap/regdomain.service create mode 100644 ap/rtw89.conf create mode 100644 ap/van-ap-dnsmasq.conf create mode 100644 ap/van-ap-dnsmasq.service create mode 100644 ap/van-ap-unmanaged.conf create mode 100644 cockpit/vanrouter/index.html create mode 100644 cockpit/vanrouter/manifest.json create mode 100644 cockpit/vanrouter/vanrouter.css create mode 100644 cockpit/vanrouter/vanrouter.js create mode 100755 deploy.sh create mode 100644 dns/99-ztuga7c2kh.network.generated create mode 100644 dns/zerotier-systemd-manager.service create mode 100644 dns/zerotier-systemd-manager.timer create mode 100644 dns/zt-network.local.conf create mode 100644 dns/zt-search.conf create mode 100755 failover/50-disable-eee create mode 100644 failover/99-van-arp.conf create mode 100644 failover/__pycache__/van-failovercpython-312.pyc create mode 100644 failover/config.json create mode 100755 failover/van-failover create mode 100644 failover/van-failover.service 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 0000000000000000000000000000000000000000..b59dfc2c17c70f8183511add6eef1505ced6105c GIT binary patch literal 13384 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 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