initial commit

This commit is contained in:
Andreas Wrede
2026-06-28 15:09:28 -04:00
parent bd865831d6
commit cc25dee7e2
27 changed files with 738 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.claude/*
+9
View File
@@ -0,0 +1,9 @@
[Match]
Name=wlxc83a35a4ee55
[Link]
RequiredForOnline=no
[Network]
Address=10.42.0.1/24
ConfigureWithoutCarrier=yes
+1
View File
@@ -0,0 +1 @@
net.ipv4.ip_forward=1
+1
View File
@@ -0,0 +1 @@
DAEMON_CONF="/etc/hostapd/hostapd.conf"
+23
View File
@@ -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
+11
View File
@@ -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
}
}
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
options rtw89_core disable_ps_mode=Y
+9
View File
@@ -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
+11
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[keyfile]
unmanaged-devices=interface-name:wlxc83a35a4ee55
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Van Router</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="../base1/cockpit.css" type="text/css" rel="stylesheet">
<link href="vanrouter.css" type="text/css" rel="stylesheet">
<script src="../base1/cockpit.js"></script>
</head>
<body>
<div class="vr-wrap">
<div class="vr-head">
<h2>Van Router</h2>
<span id="updated" class="muted">loading…</span>
</div>
<div class="card">
<div class="card-head">
<h3>Access Point</h3>
<button id="ap-restart" class="btn">Restart AP</button>
</div>
<div id="ap"></div>
</div>
<div class="card">
<h3>WAN Failover</h3>
<div id="failover"></div>
</div>
<div class="card">
<h3>WAN / Uplinks</h3>
<table>
<thead>
<tr><th>Device</th><th>Type</th><th>State</th><th>IPv4</th><th>Metric</th><th>Actions</th></tr>
</thead>
<tbody id="wan"></tbody>
</table>
<p class="muted">Lower metric = preferred default route. <span class="active-wan">★ blue</span> = current internet egress.</p>
</div>
</div>
<script src="vanrouter.js"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"version": 0,
"menu": {
"index": {
"label": "Van Router",
"order": 70
}
}
}
+18
View File
@@ -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; }
+209
View File
@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;" }[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 = `<p>hostapd: <span class="pill ${ap.active === "active" ? "ok" : "bad"}">${esc(ap.active)}</span>`;
if (beaconing)
html += ` &nbsp; SSID <b>${esc(ap.ssid)}</b> &nbsp; ${esc(ap.chan || "")} &nbsp; ${esc(ap.width || "")}`;
else
html += ` &nbsp; <span class="pill bad">not beaconing</span>`;
html += `</p><p>Clients: <b>${ap.stations.length}</b></p>`;
if (ap.stations.length) {
html += `<table><thead><tr><th>MAC</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
ap.stations.forEach(s => {
html += `<tr><td>${esc(s.mac)}</td><td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`;
});
html += `</tbody></table>`;
}
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 = `<p class="muted">van-failover daemon not running (no state file).</p>`;
return;
}
let html = `<p>Active egress: <b>${esc(fo.active_device || "—")}</b>` +
` <span class="muted">· updated ${esc(fo.updated || "")}</span></p>`;
html += `<table><thead><tr><th>WAN</th><th>Priority</th><th>Device</th><th>Status</th><th>Metric</th></tr></thead><tbody>`;
fo.wans.forEach(w => {
const status = !w.present ? `<span class="pill">absent</span>`
: w.up ? `<span class="pill ok">up</span>`
: `<span class="pill bad">down</span>`;
const prio = w.preferred ? `${esc(w.priority)} <span class="muted">(preferred)</span>` : esc(w.priority);
html += `<tr><td class="${w.active ? "active-wan" : ""}">${w.active ? "★ " : ""}${esc(w.name)}</td>` +
`<td>${prio}</td><td>${esc(w.device || "—")}</td>` +
`<td>${status}</td><td>${w.route_metric != null ? esc(w.route_metric) : "—"}</td></tr>`;
});
html += `</tbody></table>`;
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 =
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</td>` +
`<td>${esc(d.type)}</td>` +
`<td>${esc(d.state)}</td>` +
`<td>${esc(d.ip || "—")}</td>` +
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
`<td class="acts"></td>`;
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);
Executable
+50
View File
@@ -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 <nwid> allowDNS=1'."
+13
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Update zerotier per-interface DNS settings
[Timer]
OnStartupSec=1min
OnUnitInactiveSec=1min
[Install]
WantedBy=timers.target
+4
View File
@@ -0,0 +1,4 @@
allowManaged=1
allowGlobal=0
allowDefault=0
allowDNS=1
+3
View File
@@ -0,0 +1,3 @@
[Network]
Domains=
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
+8
View File
@@ -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
+4
View File
@@ -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
Binary file not shown.
+16
View File
@@ -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 }
]
}
+251
View File
@@ -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()
+13
View File
@@ -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