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:
Andreas Wrede
2026-07-06 18:32:24 -04:00
co-authored by Claude Fable 5
parent 7066207333
commit 7a92548114
3 changed files with 78 additions and 69 deletions
+5
View File
@@ -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
+71 -69
View File
@@ -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);
+2
View File
@@ -40,6 +40,8 @@ install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
echo "== cockpit plugin =="
install -d /usr/share/cockpit/vanrouter
install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
# Bridge fd headroom (Python bridge frees spawn pipes only at GC; 1024 is too tight)
install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpit-session@.service.d/nofile.conf
echo "== thermal monitor =="
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal