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";
|
"use strict";
|
||||||
|
|
||||||
// Basic Van Router dashboard for Cockpit.
|
// 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.
|
// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation.
|
||||||
|
|
||||||
// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit.
|
// 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, "'\\''") + "'";
|
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 ---------- */
|
/* ---------- Access Point ---------- */
|
||||||
|
|
||||||
async function readAP(a) {
|
function parseAP(a, i, secs) {
|
||||||
let active = "inactive";
|
const active = (secs[`active${i}`] || "").trim() || "inactive";
|
||||||
try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
let info = "";
|
const info = secs[`info${i}`] || "";
|
||||||
try { info = await run(["iw", "dev", a.iface, "info"]); } catch (e) { info = ""; }
|
|
||||||
const ssid = (info.match(/\bssid (.+)/) || [])[1];
|
const ssid = (info.match(/\bssid (.+)/) || [])[1];
|
||||||
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
|
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
|
||||||
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
|
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
|
||||||
|
|
||||||
let stations = [];
|
const stations = (secs[`stations${i}`] || "").split(/Station /).slice(1).map(b => ({
|
||||||
try {
|
|
||||||
const dump = await run(["iw", "dev", a.iface, "station", "dump"]);
|
|
||||||
stations = dump.split(/Station /).slice(1).map(b => ({
|
|
||||||
mac: b.split(" ")[0],
|
mac: b.split(" ")[0],
|
||||||
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
|
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
|
||||||
tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[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 };
|
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
|
// MAC -> { ip, host } for AP clients. Kernel neighbor table first (covers
|
||||||
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
|
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
|
||||||
const dir = {};
|
const dir = {};
|
||||||
try {
|
parseJSON(secs.neigh, []).forEach(n => {
|
||||||
JSON.parse(await run(["ip", "-j", "neigh", "show", "dev", "br0"])).forEach(n => {
|
|
||||||
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
|
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
|
||||||
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
|
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
||||||
(await run(["cat", "/var/lib/misc/dnsmasq.leases"])).trim().split("\n").forEach(l => {
|
(secs.leases || "").trim().split("\n").forEach(l => {
|
||||||
const f = l.split(" ");
|
const f = l.split(" ");
|
||||||
if (f.length >= 4)
|
if (f.length >= 4)
|
||||||
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
|
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +136,6 @@ function renderAPs(aps, dir) {
|
|||||||
|
|
||||||
/* ---------- Temperatures (van-thermal daemon state) ---------- */
|
/* ---------- 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) {
|
function renderThermal(th) {
|
||||||
const el = document.getElementById("thermal");
|
const el = document.getElementById("thermal");
|
||||||
if (!th || !th.sensors) {
|
if (!th || !th.sensors) {
|
||||||
@@ -136,14 +159,6 @@ function renderThermal(th) {
|
|||||||
|
|
||||||
/* ---------- Battery / power source (van-battery daemon state) ---------- */
|
/* ---------- 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) {
|
function renderBattery(b) {
|
||||||
const el = document.getElementById("battery");
|
const el = document.getElementById("battery");
|
||||||
if (!b || b.capacity == null) {
|
if (!b || b.capacity == null) {
|
||||||
@@ -175,14 +190,6 @@ function renderBattery(b) {
|
|||||||
|
|
||||||
/* ---------- WAN failover (van-failover daemon state) ---------- */
|
/* ---------- 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) {
|
function renderFailover(fo) {
|
||||||
const el = document.getElementById("failover");
|
const el = document.getElementById("failover");
|
||||||
if (!fo || !fo.wans) {
|
if (!fo || !fo.wans) {
|
||||||
@@ -207,26 +214,22 @@ function renderFailover(fo) {
|
|||||||
|
|
||||||
/* ---------- WAN / uplinks ---------- */
|
/* ---------- WAN / uplinks ---------- */
|
||||||
|
|
||||||
async function readWAN() {
|
function parseWAN(secs) {
|
||||||
const devOut = await run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]);
|
const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => {
|
||||||
const devs = devOut.trim().split("\n").map(line => {
|
|
||||||
const [device, type, state, ...rest] = line.split(":");
|
const [device, type, state, ...rest] = line.split(":");
|
||||||
return { device, type, state, connection: rest.join(":") };
|
return { device, type, state, connection: rest.join(":") };
|
||||||
}).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
|
}).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
|
||||||
!APS.some(a => a.iface === d.device));
|
!APS.some(a => a.iface === d.device));
|
||||||
|
|
||||||
let routes = [];
|
const routes = parseJSON(secs.routes, []);
|
||||||
try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; }
|
|
||||||
const metricByDev = {};
|
const metricByDev = {};
|
||||||
routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
|
routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
|
||||||
const activeDev = routes.length
|
const activeDev = routes.length
|
||||||
? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
|
? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let addrs = [];
|
|
||||||
try { addrs = JSON.parse(await run(["ip", "-j", "-4", "addr"])); } catch (e) { addrs = []; }
|
|
||||||
const ipByDev = {};
|
const ipByDev = {};
|
||||||
addrs.forEach(a => {
|
parseJSON(secs.addrs, []).forEach(a => {
|
||||||
const info = (a.addr_info || []).find(x => x.family === "inet");
|
const info = (a.addr_info || []).find(x => x.family === "inet");
|
||||||
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
|
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
|
||||||
});
|
});
|
||||||
@@ -302,13 +305,12 @@ async function restartAP(unit) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [aps, dir, th, bat, fo, wan] = await Promise.all([
|
const secs = parseSections(await sh(STATUS_SCRIPT));
|
||||||
Promise.all(APS.map(readAP)), readClientDir(), readThermal(), readBattery(), readFailover(), readWAN()]);
|
renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs));
|
||||||
renderAPs(aps, dir);
|
renderThermal(parseJSON(secs.thermal, null));
|
||||||
renderThermal(th);
|
renderBattery(parseJSON(secs.battery, null));
|
||||||
renderBattery(bat);
|
renderFailover(parseJSON(secs.failover, null));
|
||||||
renderFailover(fo);
|
renderWAN(parseWAN(secs));
|
||||||
renderWAN(wan);
|
|
||||||
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("updated").textContent = "error: " + (e.message || e);
|
document.getElementById("updated").textContent = "error: " + (e.message || e);
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
|
|||||||
echo "== cockpit plugin =="
|
echo "== cockpit plugin =="
|
||||||
install -d /usr/share/cockpit/vanrouter
|
install -d /usr/share/cockpit/vanrouter
|
||||||
install -m0644 cockpit/vanrouter/* /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 =="
|
echo "== thermal monitor =="
|
||||||
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
|
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
|
||||||
|
|||||||
Reference in New Issue
Block a user