Files
vanlink/cockpit/vanrouter/vanrouter.js
T
Andreas WredeandClaude Fable 5 23448294a4 cockpit: show IP + hostname for AP clients
Join station MACs against dnsmasq leases (IP + hostname), with the
kernel's br0 neighbor table as a fallback IP source for static-IP
clients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:08:03 -04:00

320 lines
13 KiB
JavaScript

"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.
// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit.
const APS = [
{ iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
];
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(a) {
let active = "inactive";
try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ }
let info = "";
try { info = await run(["iw", "dev", a.iface, "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", a.iface, "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 { band: a.band, unit: a.unit, active, ssid, chan, width, stations };
}
async function readClientDir() {
// MAC -> { ip, host } for AP clients. Kernel neighbor table first (covers
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
const dir = {};
try {
JSON.parse(await run(["ip", "-j", "neigh", "show", "dev", "br0"])).forEach(n => {
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
});
} catch (e) { /* ignore */ }
try {
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
(await run(["cat", "/var/lib/misc/dnsmasq.leases"])).trim().split("\n").forEach(l => {
const f = l.split(" ");
if (f.length >= 4)
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
});
} catch (e) { /* ignore */ }
return dir;
}
function renderAPs(aps, dir) {
const el = document.getElementById("ap");
el.innerHTML = "";
aps.forEach(ap => {
const beaconing = !!ap.ssid;
const div = document.createElement("div");
div.className = "ap-band";
let html = `<p><b>${esc(ap.band)}</b> &nbsp; ${esc(ap.unit)}: ` +
`<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 += ` &nbsp; Clients: <b>${ap.stations.length}</b>` +
` <button class="btn ap-restart">Restart</button></p>`;
if (ap.stations.length) {
html += `<table><thead><tr><th>MAC</th><th>IP</th><th>Hostname</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
ap.stations.forEach(s => {
const c = dir[s.mac.toLowerCase()] || {};
html += `<tr><td>${esc(s.mac)}</td><td>${esc(c.ip || "—")}</td><td>${esc(c.host || "—")}</td>` +
`<td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`;
});
html += `</tbody></table>`;
}
div.innerHTML = html;
div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
el.appendChild(div);
});
}
/* ---------- Temperatures (van-thermal daemon state) ---------- */
async function readThermal() {
try {
return JSON.parse(await run(["cat", "/run/van-thermal/state.json"]));
} catch (e) {
return null;
}
}
function renderThermal(th) {
const el = document.getElementById("thermal");
if (!th || !th.sensors) {
el.innerHTML = `<p class="muted">van-thermal daemon not running (no state file).</p>`;
return;
}
const pillClass = { ok: "ok", warn: "warn", crit: "bad" };
let html = `<table><thead><tr><th>Sensor</th><th>Temp</th><th>Status</th><th>Warn / Crit</th></tr></thead><tbody>`;
Object.keys(th.sensors).forEach(name => {
const s = th.sensors[name];
const temp = s.temp == null ? "—" : `${esc(s.temp)} °C`;
const lvl = s.level || "ok";
html += `<tr><td>${esc(name.toUpperCase())}</td><td>${temp}</td>` +
`<td><span class="pill ${pillClass[lvl] || ""}">${esc(lvl)}</span></td>` +
`<td class="muted">${esc(s.warn)} / ${esc(s.crit)} °C</td></tr>`;
});
html += `</tbody></table>`;
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
el.innerHTML = html;
}
/* ---------- Battery / power source (van-battery daemon state) ---------- */
async function readBattery() {
try {
return JSON.parse(await run(["cat", "/run/van-battery/state.json"]));
} catch (e) {
return null;
}
}
function renderBattery(b) {
const el = document.getElementById("battery");
if (!b || b.capacity == null) {
el.innerHTML = `<p class="muted">van-battery daemon not running (no state file).</p>`;
return;
}
const cap = b.capacity;
const shut = b.shutdown_level != null ? b.shutdown_level : 10;
const warns = b.warn_levels || [];
const lowest = warns.length ? Math.min.apply(null, warns) : 25;
// Charge severity only bites while on battery; on mains it's just charging/full.
let lvl = "ok";
if (b.on_battery) lvl = cap <= shut ? "crit" : cap <= lowest ? "warn" : "ok";
const pillClass = { ok: "ok", warn: "warn", crit: "bad" }[lvl];
const src = b.on_battery
? `<span class="pill warn">on battery</span>`
: `<span class="pill ok">on mains</span>`;
let html = `<p>Power: ${src} &nbsp; Charge: <span class="pill ${pillClass}">${esc(cap)}%</span></p>`;
html += `<div class="bar"><div class="bar-fill ${lvl}" style="width:${Math.max(0, Math.min(100, cap))}%"></div></div>`;
if (b.on_battery && b.alerted_below != null)
html += `<p class="muted">Low-battery alert sent at ${esc(b.alerted_below)}%.</p>`;
html += `<p class="muted">On battery: alerts at ${esc(warns.join("/"))}%, auto-shutdown at ${esc(shut)}%.` +
` Alerts: <code>journalctl -u van-battery</code></p>`;
el.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") &&
!APS.some(a => a.iface === d.device));
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(unit) {
try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
catch (e) { window.alert("Restart failed: " + e.message); }
setTimeout(refresh, 2500);
}
/* ---------- loop ---------- */
async function refresh() {
try {
const [aps, dir, th, bat, fo, wan] = await Promise.all([
Promise.all(APS.map(readAP)), readClientDir(), readThermal(), readBattery(), readFailover(), readWAN()]);
renderAPs(aps, dir);
renderThermal(th);
renderBattery(bat);
renderFailover(fo);
renderWAN(wan);
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
} catch (e) {
document.getElementById("updated").textContent = "error: " + (e.message || e);
}
}
refresh();
setInterval(refresh, 5000);