initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.claude/*
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Match]
|
||||||
|
Name=wlxc83a35a4ee55
|
||||||
|
|
||||||
|
[Link]
|
||||||
|
RequiredForOnline=no
|
||||||
|
|
||||||
|
[Network]
|
||||||
|
Address=10.42.0.1/24
|
||||||
|
ConfigureWithoutCarrier=yes
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
net.ipv4.ip_forward=1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DAEMON_CONF="/etc/hostapd/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
|
||||||
Executable
+11
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
options rtw89_core disable_ps_mode=Y
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[keyfile]
|
||||||
|
unmanaged-devices=interface-name:wlxc83a35a4ee55
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"menu": {
|
||||||
|
"index": {
|
||||||
|
"label": "Van Router",
|
||||||
|
"order": 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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 = `<p>hostapd: <span class="pill ${ap.active === "active" ? "ok" : "bad"}">${esc(ap.active)}</span>`;
|
||||||
|
if (beaconing)
|
||||||
|
html += ` SSID <b>${esc(ap.ssid)}</b> ${esc(ap.chan || "")} ${esc(ap.width || "")}`;
|
||||||
|
else
|
||||||
|
html += ` <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);
|
||||||
@@ -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'."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Update zerotier per-interface DNS settings
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnStartupSec=1min
|
||||||
|
OnUnitInactiveSec=1min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allowManaged=1
|
||||||
|
allowGlobal=0
|
||||||
|
allowDefault=0
|
||||||
|
allowDNS=1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Network]
|
||||||
|
Domains=
|
||||||
|
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
|
||||||
Executable
+8
@@ -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
|
||||||
@@ -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.
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+251
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user