From 7a925481148ccd064cf43003a3b74e0512e0f531 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 6 Jul 2026 18:32:24 -0400 Subject: [PATCH] =?UTF-8?q?cockpit:=20fix=20bridge=20EMFILE=20=E2=80=94=20?= =?UTF-8?q?batch=20status=20reads=20into=20one=20spawn,=20raise=20fd=20lim?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:@@ 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 --- cockpit/cockpit-session-nofile.conf | 5 + cockpit/vanrouter/vanrouter.js | 140 ++++++++++++++-------------- deploy.sh | 2 + 3 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 cockpit/cockpit-session-nofile.conf diff --git a/cockpit/cockpit-session-nofile.conf b/cockpit/cockpit-session-nofile.conf new file mode 100644 index 0000000..1f33d6e --- /dev/null +++ b/cockpit/cockpit-session-nofile.conf @@ -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 diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js index e28e94b..205ded1 100644 --- a/cockpit/vanrouter/vanrouter.js +++ b/cockpit/vanrouter/vanrouter.js @@ -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:@@ 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: - (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: + (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); diff --git a/deploy.sh b/deploy.sh index 77f7311..6c92080 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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