|
|
|
@@ -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);
|