cockpit: fix bridge EMFILE — batch status reads into one spawn, raise fd limit
The Python cockpit-bridge frees spawn-pipe fds only at GC time; the Van Router page's ~12 cockpit.spawn calls every 5s saw-toothed the bridge to its 1024-fd soft limit, so the polkit admin-escalation spawn failed with "Too many files open" whenever it landed near a peak. Two-sided fix: - vanrouter.js gathers all read-only status in ONE `sh -c` spawn per refresh, sections delimited by @@vr:<name>@@ marker lines (~12x less pipe churn). Mutating actions unchanged. - cockpit-session@.service drop-in raises LimitNOFILE to 65535 (hard limit is 524288), installed by deploy.sh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7066207333
commit
7a92548114
@@ -0,0 +1,5 @@
|
||||
# cockpit-session@.service drop-in: the Python cockpit-bridge frees spawn-pipe
|
||||
# fds only at GC time; the stock 1024 soft limit is too tight for a polling
|
||||
# dashboard and EMFILEs the polkit/sudo escalation spawn. Hard limit is 524288.
|
||||
[Service]
|
||||
LimitNOFILE=65535
|
||||
@@ -1,7 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
// Basic Van Router dashboard for Cockpit.
|
||||
// Read-only status via cockpit.spawn (logged-in user); mutating actions use
|
||||
// All read-only status is gathered in ONE cockpit.spawn per refresh: the Python
|
||||
// bridge only frees spawn-pipe fds at GC time, so a dozen spawns every 5s marched
|
||||
// it into its 1024-fd limit (EMFILE on admin escalation). 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.
|
||||
@@ -26,49 +28,78 @@ function esc_sh(s) {
|
||||
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/* ---------- one-spawn status collection ---------- */
|
||||
|
||||
// Each section's output is preceded by a @@vr:<name>@@ marker line.
|
||||
const STATUS_SCRIPT = (() => {
|
||||
const parts = [];
|
||||
const add = (name, cmd) =>
|
||||
parts.push(`printf '\\n@@vr:%s@@\\n' ${esc_sh(name)}; { ${cmd}; } 2>/dev/null || true`);
|
||||
APS.forEach((a, i) => {
|
||||
add(`active${i}`, `systemctl is-active ${a.unit}`);
|
||||
add(`info${i}`, `iw dev ${a.iface} info`);
|
||||
add(`stations${i}`, `iw dev ${a.iface} station dump`);
|
||||
});
|
||||
add("neigh", "ip -j neigh show dev br0");
|
||||
add("leases", "cat /var/lib/misc/dnsmasq.leases");
|
||||
add("thermal", "cat /run/van-thermal/state.json");
|
||||
add("battery", "cat /run/van-battery/state.json");
|
||||
add("failover", "cat /run/van-failover/state.json");
|
||||
add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status");
|
||||
add("routes", "ip -j route show default");
|
||||
add("addrs", "ip -j -4 addr");
|
||||
return parts.join("\n");
|
||||
})();
|
||||
|
||||
function parseSections(out) {
|
||||
const secs = {};
|
||||
let cur = null;
|
||||
out.split("\n").forEach(line => {
|
||||
const m = line.match(/^@@vr:(\w+)@@$/);
|
||||
if (m) { cur = m[1]; secs[cur] = []; }
|
||||
else if (cur !== null) secs[cur].push(line);
|
||||
});
|
||||
Object.keys(secs).forEach(k => { secs[k] = secs[k].join("\n"); });
|
||||
return secs;
|
||||
}
|
||||
|
||||
function parseJSON(text, fallback) {
|
||||
try { return JSON.parse(text); } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
/* ---------- Access Point ---------- */
|
||||
|
||||
async function readAP(a) {
|
||||
let active = "inactive";
|
||||
try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ }
|
||||
function parseAP(a, i, secs) {
|
||||
const active = (secs[`active${i}`] || "").trim() || "inactive";
|
||||
|
||||
let info = "";
|
||||
try { info = await run(["iw", "dev", a.iface, "info"]); } catch (e) { info = ""; }
|
||||
const info = secs[`info${i}`] || "";
|
||||
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 = []; }
|
||||
const stations = (secs[`stations${i}`] || "").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]
|
||||
}));
|
||||
|
||||
return { band: a.band, unit: a.unit, active, ssid, chan, width, stations };
|
||||
}
|
||||
|
||||
async function readClientDir() {
|
||||
function parseClientDir(secs) {
|
||||
// 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 */ }
|
||||
parseJSON(secs.neigh, []).forEach(n => {
|
||||
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
|
||||
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
|
||||
});
|
||||
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
||||
(secs.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] };
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -105,14 +136,6 @@ function renderAPs(aps, dir) {
|
||||
|
||||
/* ---------- 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) {
|
||||
@@ -136,14 +159,6 @@ function renderThermal(th) {
|
||||
|
||||
/* ---------- 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) {
|
||||
@@ -175,14 +190,6 @@ function renderBattery(b) {
|
||||
|
||||
/* ---------- 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) {
|
||||
@@ -207,26 +214,22 @@ function renderFailover(fo) {
|
||||
|
||||
/* ---------- 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 => {
|
||||
function parseWAN(secs) {
|
||||
const devs = (secs.devices || "").trim().split("\n").filter(Boolean).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 routes = parseJSON(secs.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 => {
|
||||
parseJSON(secs.addrs, []).forEach(a => {
|
||||
const info = (a.addr_info || []).find(x => x.family === "inet");
|
||||
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
|
||||
});
|
||||
@@ -302,13 +305,12 @@ async function restartAP(unit) {
|
||||
|
||||
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);
|
||||
const secs = parseSections(await sh(STATUS_SCRIPT));
|
||||
renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs));
|
||||
renderThermal(parseJSON(secs.thermal, null));
|
||||
renderBattery(parseJSON(secs.battery, null));
|
||||
renderFailover(parseJSON(secs.failover, null));
|
||||
renderWAN(parseWAN(secs));
|
||||
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
document.getElementById("updated").textContent = "error: " + (e.message || e);
|
||||
|
||||
Reference in New Issue
Block a user