1167 lines
43 KiB
JavaScript
1167 lines
43 KiB
JavaScript
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const { spawnSync } = require("node:child_process");
|
||
|
|
const { EventEmitter } = require("node:events");
|
||
|
|
|
||
|
|
const { createSessionOpsApi } = require("./sessionOps.cjs");
|
||
|
|
|
||
|
|
function quoteShellArg(value) {
|
||
|
|
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
||
|
|
}
|
||
|
|
|
||
|
|
function unwrapExecShC(command) {
|
||
|
|
const prefix = "exec sh -c ";
|
||
|
|
if (!String(command).startsWith(prefix)) return command;
|
||
|
|
const quoted = command.slice(prefix.length);
|
||
|
|
if (!quoted.startsWith("'") || !quoted.endsWith("'")) return command;
|
||
|
|
return quoted.slice(1, -1).replace(/'\\''/g, "'");
|
||
|
|
}
|
||
|
|
const { selectServerStatsFixtureOutput } = require("./serverStatsTestHelpers.cjs");
|
||
|
|
const {
|
||
|
|
borrowTransport,
|
||
|
|
createTransport,
|
||
|
|
findTransportByEndpoint,
|
||
|
|
getTransportStats,
|
||
|
|
resetSshTransportRegistryForTests,
|
||
|
|
} = require("../sshConnectionPool.cjs");
|
||
|
|
|
||
|
|
// A fake ssh2 exec stream that emits the canned stdout then closes.
|
||
|
|
function fakeStream(stdout) {
|
||
|
|
const stream = new EventEmitter();
|
||
|
|
stream.stderr = new EventEmitter();
|
||
|
|
let sent = false;
|
||
|
|
const send = () => {
|
||
|
|
if (sent) return;
|
||
|
|
sent = true;
|
||
|
|
if (stdout) stream.emit("data", Buffer.from(stdout));
|
||
|
|
stream.emit("close", 0);
|
||
|
|
};
|
||
|
|
stream.write = () => true;
|
||
|
|
setImmediate(send);
|
||
|
|
return stream;
|
||
|
|
}
|
||
|
|
|
||
|
|
// A fake connection whose exec() always returns the same canned Linux stats
|
||
|
|
// line so getServerStats parses a successful result.
|
||
|
|
function fakeConn(stdout) {
|
||
|
|
return {
|
||
|
|
exec(command, cb) {
|
||
|
|
cb(null, fakeStream(selectServerStatsFixtureOutput(command, stdout)));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Minimal Linux stats payload: enough for the parser to report success
|
||
|
|
// (memTotal present). CPU needs two samples for a delta, which is fine — the
|
||
|
|
// success gate only requires cpu OR memTotal OR cpuCores to be non-null.
|
||
|
|
const LINUX_STATS =
|
||
|
|
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:";
|
||
|
|
const MACOS_STATS =
|
||
|
|
"NC_LATENCY_MARK|CPU:27|CORES:10|MEMINFO:32768 4096 0 8192 2048 1536|PROCS:123;1.2;Finder|DISKS:/:120:460:26:apfs:/dev/disk3|NET:en0:1000:3000";
|
||
|
|
|
||
|
|
function makeSessionOps(sessions) {
|
||
|
|
return createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
measureTcpConnectLatency: async () => 3,
|
||
|
|
// The rest of the sessionOps surface isn't exercised by getServerStats.
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function runStatsCommandWithBusyBoxTools(command) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 4; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() {",
|
||
|
|
" printf '%s\\n' 'Mem: 256000K used, 768000K free, 0K shrd, 0K buff, 0K cached'",
|
||
|
|
" printf '%s\\n' 'CPU: 0% usr 0% sys 0% nic 100% idle 0% io 0% irq 0% sirq'",
|
||
|
|
" printf '%s\\n' ' PID PPID USER STAT VSZ %VSZ %CPU COMMAND'",
|
||
|
|
" printf '%s\\n' ' 1 0 root S 2048 2% 3% /sbin/procd'",
|
||
|
|
"}",
|
||
|
|
"mount() { return 1; }",
|
||
|
|
"df() {",
|
||
|
|
" if [ \"$1\" = '-kPT' ]; then return 1; fi",
|
||
|
|
" if [ \"$1\" = '-BG' ]; then return 1; fi",
|
||
|
|
" printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" printf '%s\\n' 'overlayfs:/overlay 1048576 262144 786432 25% /'",
|
||
|
|
" printf '%s\\n' '/dev/loop0 131072 131072 0 100% /snap/example/1'",
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
function runStatsCommandWithBusyBoxSmpTop(command) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 4; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() {",
|
||
|
|
" printf '%s\\n' 'Mem: 256000K used, 768000K free, 0K shrd, 0K buff, 0K cached'",
|
||
|
|
" printf '%s\\n' 'CPU: 0% usr 0% sys 0% nic 100% idle 0% io 0% irq 0% sirq'",
|
||
|
|
" printf '%s\\n' ' PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND'",
|
||
|
|
" printf '%s\\n' ' 1 0 root S 2048 2.0 0 3.0 /sbin/procd sh -c echo a,b|c'",
|
||
|
|
"}",
|
||
|
|
"df() { return 1; }",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Proxmox LXC (CT) guests often expose ZFS datasets / host bind mounts as the
|
||
|
|
// df "Filesystem" column instead of /dev/* block devices.
|
||
|
|
function runStatsCommandWithPveCtDf(command) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 2; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() { return 1; }",
|
||
|
|
"mount() { return 1; }",
|
||
|
|
"df() {",
|
||
|
|
" path=",
|
||
|
|
" for a in \"$@\"; do",
|
||
|
|
" case \"$a\" in /*) path=$a ;; esac",
|
||
|
|
" done",
|
||
|
|
" if [ \"$1\" = '-kPT' ]; then",
|
||
|
|
" printf '%s\\n' 'Filesystem Type 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" if [ -n \"$path\" ]; then",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-0 zfs 8388608 1048576 7340032 13% /'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-0 zfs 8388608 1048576 7340032 13% /'",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-1 zfs 20971520 5242880 15728640 25% /mnt/data'",
|
||
|
|
" printf '%s\\n' '/tank/shared ext4 104857600 52428800 52428800 50% /srv'",
|
||
|
|
" printf '%s\\n' 'tmpfs tmpfs 102400 100 102300 1% /run'",
|
||
|
|
" printf '%s\\n' 'udev devtmpfs 1024652 0 1024652 0% /dev'",
|
||
|
|
" printf '%s\\n' '/dev/loop0 squashfs 131072 131072 0 100% /snap/example/1'",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-2 zfs 4194304 1048576 3145728 - /mnt/scratch'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" if [ -n \"$path\" ]; then",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-0 8388608 1048576 7340032 13% /'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-0 8388608 1048576 7340032 13% /'",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-1 20971520 5242880 15728640 25% /mnt/data'",
|
||
|
|
" printf '%s\\n' '/tank/shared 104857600 52428800 52428800 50% /srv'",
|
||
|
|
" printf '%s\\n' 'tmpfs 102400 100 102300 1% /run'",
|
||
|
|
" printf '%s\\n' 'udev 1024652 0 1024652 0% /dev'",
|
||
|
|
" printf '%s\\n' '/dev/loop0 131072 131072 0 100% /snap/example/1'",
|
||
|
|
" printf '%s\\n' 'rpool/data/subvol-101-disk-2 4194304 1048576 3145728 - /mnt/scratch'",
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
test("getServerStats falls back to BusyBox tools and excludes loop-backed images", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "router.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithBusyBoxTools(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.topProcesses, [
|
||
|
|
{ pid: "1", memPercent: 2, command: "/sbin/procd" },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 0.25, total: 1, percent: 25, capacityKey: "overlayfs:/overlay" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 25);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats keeps PVE CT ZFS/bind mounts and recovers dash Capacity", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "ct.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithPveCtDf(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 1, total: 8, percent: 13, capacityKey: "rpool/data/subvol-101-disk-0", filesystemType: "zfs" },
|
||
|
|
{ mountPoint: "/mnt/data", used: 5, total: 20, percent: 25, capacityKey: "rpool/data/subvol-101-disk-1", filesystemType: "zfs" },
|
||
|
|
{ mountPoint: "/srv", used: 50, total: 100, percent: 50, capacityKey: "/tank/shared", filesystemType: "ext4" },
|
||
|
|
{ mountPoint: "/mnt/scratch", used: 1, total: 4, percent: 25, capacityKey: "rpool/data/subvol-101-disk-2", filesystemType: "zfs" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 13);
|
||
|
|
assert.equal(result.stats.diskUsed, 1);
|
||
|
|
assert.equal(result.stats.diskTotal, 8);
|
||
|
|
});
|
||
|
|
|
||
|
|
// rclone / CloudDrive / union-style FUSE mounts expose cloud quotas that should
|
||
|
|
// not inflate System Overview disk totals after the PVE CT filter broadening.
|
||
|
|
function runStatsCommandWithNetworkFuseDf(command, { forceLegacy = false } = {}) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 2; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() { return 1; }",
|
||
|
|
"mount() {",
|
||
|
|
" printf '%s\\n' 'remote:gdrive on /mnt/rclone type fuse.rclone (rw)'",
|
||
|
|
" printf '%s\\n' 'remote:gdrive on /mnt/gdrive type fuse.rclone (rw)'",
|
||
|
|
" printf '%s\\n' 'user@host:/media on /mnt/sshfs type fuse.sshfs (rw)'",
|
||
|
|
" printf '%s\\n' 'CloudNAS on /CloudNAS type fuse.CloudDrive (rw)'",
|
||
|
|
" printf '%s\\n' 'ufs-backend on /mnt/ufs type fuse.ufs (rw)'",
|
||
|
|
" printf '%s\\n' 'nas.local:/volume1/media on /mnt/nas type nfs4 (rw)'",
|
||
|
|
" printf '%s\\n' '//nas.local/share on /mnt/smb type cifs (rw)'",
|
||
|
|
" printf '%s\\n' 'mergerfs on /mnt/pool type fuse.mergerfs (rw)'",
|
||
|
|
" printf '%s\\n' 'gluster on /mnt/gluster type fuse.glusterfs (rw)'",
|
||
|
|
" printf '%s\\n' 'ceph-fuse on /mnt/ceph type fuse.ceph-fuse (rw)'",
|
||
|
|
" printf '%s\\n' 'unionfs on /mnt/unionfs type fuse.unionfs-fuse (rw)'",
|
||
|
|
" printf '%s\\n' 'CloudFS on /mnt/CloudNAS/openlist type fuse (rw)'",
|
||
|
|
"}",
|
||
|
|
"df() {",
|
||
|
|
...(forceLegacy ? [" if [ \"$1\" = '-kPT' ]; then return 1; fi"] : []),
|
||
|
|
" path=",
|
||
|
|
" for a in \"$@\"; do",
|
||
|
|
" case \"$a\" in /*) path=$a ;; esac",
|
||
|
|
" done",
|
||
|
|
" if [ \"$1\" = '-kPT' ]; then",
|
||
|
|
" printf '%s\\n' 'Filesystem Type 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" if [ -n \"$path\" ]; then",
|
||
|
|
" printf '%s\\n' '/dev/sda1 ext4 104857600 20971520 83886080 20% /'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' '/dev/sda1 ext4 104857600 20971520 83886080 20% /'",
|
||
|
|
" printf '%s\\n' '/dev/sdb1 ext4 52428800 10485760 41943040 20% /data'",
|
||
|
|
" printf '%s\\n' 'remote:gdrive fuse.rclone 1073741824 536870912 536870912 50% /mnt/rclone'",
|
||
|
|
" printf '%s\\n' 'user@host:/media fuse.sshfs 2147483648 1073741824 1073741824 50% /mnt/sshfs'",
|
||
|
|
" printf '%s\\n' 'CloudNAS fuse.CloudDrive 4294967296 2147483648 2147483648 50% /CloudNAS'",
|
||
|
|
" printf '%s\\n' 'ufs-backend fuse.ufs 1048576000 524288000 524288000 50% /mnt/ufs'",
|
||
|
|
" printf '%s\\n' 'nas.local:/volume1/media nfs4 20971520000 8388608000 12582912000 40% /mnt/nas'",
|
||
|
|
" printf '%s\\n' '//nas.local/share cifs 10485760000 4194304000 6291456000 40% /mnt/smb'",
|
||
|
|
" printf '%s\\n' 'mergerfs fuse.mergerfs 31457280000 9437184000 22020096000 30% /mnt/pool'",
|
||
|
|
" printf '%s\\n' 'gluster fuse.glusterfs 15728640000 7340032000 8388608000 47% /mnt/gluster'",
|
||
|
|
" printf '%s\\n' 'ceph-fuse fuse.ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
|
||
|
|
" printf '%s\\n' 'unionfs fuse.unionfs-fuse 8388608000 2097152000 6291456000 25% /mnt/unionfs'",
|
||
|
|
" printf '%s\\n' 'CloudFS fuse 10995116277760 0 10995116277760 0% /mnt/CloudNAS/openlist'",
|
||
|
|
" printf '%s\\n' 'tmpfs tmpfs 102400 100 102300 1% /run'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" if [ -n \"$path\" ]; then",
|
||
|
|
" printf '%s\\n' '/dev/sda1 104857600 20971520 83886080 20% /'",
|
||
|
|
" return 0",
|
||
|
|
" fi",
|
||
|
|
" printf '%s\\n' '/dev/sda1 104857600 20971520 83886080 20% /'",
|
||
|
|
" printf '%s\\n' '/dev/sdb1 52428800 10485760 41943040 20% /data'",
|
||
|
|
" printf '%s\\n' 'fuse.rclone 1073741824 536870912 536870912 50% /mnt/rclone'",
|
||
|
|
" printf '%s\\n' 'remote:gdrive:media 2147483648 1073741824 1073741824 50% /mnt/gdrive'",
|
||
|
|
" printf '%s\\n' 'user@host:/media 2147483648 1073741824 1073741824 50% /mnt/sshfs'",
|
||
|
|
" printf '%s\\n' 'CloudDrive 4294967296 2147483648 2147483648 50% /CloudNAS/CloudDrive'",
|
||
|
|
" printf '%s\\n' 'ufs 1048576000 524288000 524288000 50% /mnt/ufs'",
|
||
|
|
" printf '%s\\n' 'nas.local:/volume1/media 20971520000 8388608000 12582912000 40% /mnt/nas'",
|
||
|
|
" printf '%s\\n' '//nas.local/share 10485760000 4194304000 6291456000 40% /mnt/smb'",
|
||
|
|
" printf '%s\\n' 'mergerfs 31457280000 9437184000 22020096000 30% /mnt/pool'",
|
||
|
|
" printf '%s\\n' 'gluster 15728640000 7340032000 8388608000 47% /mnt/gluster'",
|
||
|
|
" printf '%s\\n' 'ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
|
||
|
|
" printf '%s\\n' 'unionfs 8388608000 2097152000 6291456000 25% /mnt/unionfs'",
|
||
|
|
" printf '%s\\n' 'tmpfs 102400 100 102300 1% /run'",
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
function runStatsCommandWithRootFuseDf(command, filesystemType = "fuse.rclone") {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 2; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() { return 1; }",
|
||
|
|
"mount() { return 1; }",
|
||
|
|
"df() {",
|
||
|
|
" if [ \"$1\" != '-kPT' ]; then return 1; fi",
|
||
|
|
" printf '%s\\n' 'Filesystem Type 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
` printf '%s\\n' 'remote:gdrive ${filesystemType} 1073741824 536870912 536870912 50% /'`,
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
test("getServerStats excludes FUSE mounts using df filesystem types", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "nas.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithNetworkFuseDf(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 20, total: 100, percent: 20, capacityKey: "/dev/sda1", filesystemType: "ext4" },
|
||
|
|
{ mountPoint: "/data", used: 10, total: 50, percent: 20, capacityKey: "/dev/sdb1", filesystemType: "ext4" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 20);
|
||
|
|
assert.equal(result.stats.diskUsed, 20);
|
||
|
|
assert.equal(result.stats.diskTotal, 100);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats uses mount metadata when df filesystem types are unavailable", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "legacy-fuse.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithNetworkFuseDf(command, { forceLegacy: true });
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 20, total: 100, percent: 20, capacityKey: "/dev/sda1" },
|
||
|
|
{ mountPoint: "/data", used: 10, total: 50, percent: 20, capacityKey: "/dev/sdb1" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 20);
|
||
|
|
assert.equal(result.stats.diskUsed, 20);
|
||
|
|
assert.equal(result.stats.diskTotal, 100);
|
||
|
|
});
|
||
|
|
|
||
|
|
function runStatsCommandWithUntypedScopedIpv6NfsDf(command) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 2; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() { return 1; }",
|
||
|
|
"mount() { return 1; }",
|
||
|
|
"df() {",
|
||
|
|
" if [ \"$1\" = '-kPT' ]; then return 1; fi",
|
||
|
|
" printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" printf '%s\\n' '/dev/sda1 104857600 20971520 83886080 20% /'",
|
||
|
|
" printf '%s\\n' '[fe80::1%eth0]:/export 20971520000 8388608000 12582912000 40% /mnt/nfs6'",
|
||
|
|
" printf '%s\\n' 'ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
|
||
|
|
" printf '%s\\n' 'gluster 15728640000 7340032000 8388608000 47% /mnt/gluster'",
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
test("getServerStats excludes scoped IPv6 NFS sources without filesystem types", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "nfs6.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithUntypedScopedIpv6NfsDf(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 20, total: 100, percent: 20, capacityKey: "/dev/sda1" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 20);
|
||
|
|
assert.equal(result.stats.diskUsed, 20);
|
||
|
|
assert.equal(result.stats.diskTotal, 100);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats does not fall back to a root FUSE quota", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "fuse-root.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithRootFuseDf(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, []);
|
||
|
|
assert.equal(result.stats.diskPercent, null);
|
||
|
|
assert.equal(result.stats.diskUsed, null);
|
||
|
|
assert.equal(result.stats.diskTotal, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats keeps a local fuseblk root filesystem", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "ntfs-root.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithRootFuseDf(command, "fuseblk");
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 512, total: 1024, percent: 50, capacityKey: "remote:gdrive", filesystemType: "fuseblk" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 50);
|
||
|
|
assert.equal(result.stats.diskUsed, 512);
|
||
|
|
assert.equal(result.stats.diskTotal, 1024);
|
||
|
|
});
|
||
|
|
|
||
|
|
function runStatsCommandWithLoopRootDf(command) {
|
||
|
|
command = unwrapExecShC(command);
|
||
|
|
const script = [
|
||
|
|
"uname() { printf '%s\\n' Linux; }",
|
||
|
|
"nproc() { printf '%s\\n' 2; }",
|
||
|
|
"ps() { return 1; }",
|
||
|
|
"top() { return 1; }",
|
||
|
|
"mount() { return 1; }",
|
||
|
|
"df() {",
|
||
|
|
" if [ \"$1\" != '-kPT' ]; then return 1; fi",
|
||
|
|
" printf '%s\\n' 'Filesystem Type 1024-blocks Used Available Capacity Mounted on'",
|
||
|
|
" printf '%s\\n' '/dev/loop0 ext4 8388608 2097152 6291456 25% /'",
|
||
|
|
" printf '%s\\n' '/dev/loop1 squashfs 131072 131072 0 100% /snap/example/1'",
|
||
|
|
"}",
|
||
|
|
command,
|
||
|
|
].join("\n");
|
||
|
|
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||
|
|
}
|
||
|
|
|
||
|
|
test("getServerStats keeps a loop-backed root while skipping snap loops", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "ct-loop.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithLoopRootDf(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 2, total: 8, percent: 25, capacityKey: "/dev/loop0", filesystemType: "ext4" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 25);
|
||
|
|
assert.equal(result.stats.diskUsed, 2);
|
||
|
|
assert.equal(result.stats.diskTotal, 8);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats reads commands after BusyBox top's CPU column", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "router.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
const execution = runStatsCommandWithBusyBoxSmpTop(command);
|
||
|
|
assert.equal(execution.status, 0, execution.stderr);
|
||
|
|
cb(null, fakeStream(execution.stdout));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.topProcesses, [
|
||
|
|
{ pid: "1", memPercent: 2, command: "/sbin/procd" },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats opens a Mosh stats companion connection when session.conn is missing", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = { type: "mosh", moshStatsAuth: { hostname: "h", password: "p" } };
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
let ensureCalls = 0;
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
ensureMoshStatsConnection: async (s, id) => {
|
||
|
|
ensureCalls += 1;
|
||
|
|
assert.equal(s, session);
|
||
|
|
assert.equal(id, "sid");
|
||
|
|
// Simulate a successful companion connection. The real helper stores it
|
||
|
|
// on moshStatsConn (NOT conn) so it stays invisible to other bridges.
|
||
|
|
s.moshStatsConn = fakeConn(LINUX_STATS);
|
||
|
|
return s.moshStatsConn;
|
||
|
|
},
|
||
|
|
measureTcpConnectLatency: async () => 3,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(ensureCalls, 1);
|
||
|
|
// session.conn must remain unset — only moshStatsConn carries the companion.
|
||
|
|
assert.equal(session.conn, undefined);
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(result.stats.memTotal, 8000);
|
||
|
|
assert.equal(result.stats.cpuCores, 4);
|
||
|
|
assert.equal(typeof result.stats.latencyMs, "number");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats fails gracefully when the companion connection cannot be established", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = { type: "mosh", moshStatsAuth: { hostname: "h" } };
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
ensureMoshStatsConnection: async () => null, // no usable auth, etc.
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
assert.match(result.error, /not connected/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats does not touch the companion path for a normal SSH session", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = { type: "ssh", conn: fakeConn(LINUX_STATS) };
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
let ensureCalls = 0;
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
ensureMoshStatsConnection: async () => {
|
||
|
|
ensureCalls += 1;
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(ensureCalls, 0);
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats measures TCP connectivity instead of SSH protocol latency", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = {
|
||
|
|
type: "mosh",
|
||
|
|
hostname: "vm.example.test",
|
||
|
|
moshStatsAuth: { hostname: "vm.example.test", port: 2222 },
|
||
|
|
moshStatsConn: fakeConn(LINUX_STATS),
|
||
|
|
};
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const probes = [];
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
sessions,
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
measureTcpConnectLatency: async (target) => {
|
||
|
|
probes.push(target);
|
||
|
|
return 2;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(result.stats.latencyMs, 2);
|
||
|
|
assert.deepEqual(probes, [{ hostname: "vm.example.test", port: 2222 }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats skips a misleading direct probe for jump-host sessions", async () => {
|
||
|
|
const sessions = new Map([["sid", {
|
||
|
|
type: "mosh",
|
||
|
|
moshStatsAuth: { hostname: "private.example.test", port: 22, hasJumpHost: true },
|
||
|
|
moshStatsConn: fakeConn(LINUX_STATS),
|
||
|
|
}]]);
|
||
|
|
let probeCalls = 0;
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
sessions,
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
measureTcpConnectLatency: async () => {
|
||
|
|
probeCalls += 1;
|
||
|
|
return 2;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(result.stats.latencyMs, null);
|
||
|
|
assert.equal(probeCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats closes a blocked probe channel when stats time out", async () => {
|
||
|
|
let fireTimeout;
|
||
|
|
let closeCalls = 0;
|
||
|
|
const stream = new EventEmitter();
|
||
|
|
stream.stderr = new EventEmitter();
|
||
|
|
stream.write = () => true;
|
||
|
|
stream.close = () => { closeCalls += 1; };
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
sessions: new Map([["sid", { type: "ssh", conn: { exec: (_command, cb) => cb(null, stream) } }]]),
|
||
|
|
Date,
|
||
|
|
setTimeout: (callback) => {
|
||
|
|
fireTimeout = callback;
|
||
|
|
return 1;
|
||
|
|
},
|
||
|
|
clearTimeout: () => {},
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
});
|
||
|
|
|
||
|
|
const pending = api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
fireTimeout();
|
||
|
|
const result = await pending;
|
||
|
|
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
assert.match(result.error, /Timeout/);
|
||
|
|
assert.equal(closeCalls, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats closes a stats stream delivered after timeout", async () => {
|
||
|
|
let execCallback;
|
||
|
|
let fireTimeout;
|
||
|
|
let closeCalls = 0;
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
sessions: new Map([["sid", {
|
||
|
|
type: "ssh",
|
||
|
|
conn: { exec: (_command, callback) => { execCallback = callback; } },
|
||
|
|
}]]),
|
||
|
|
Date,
|
||
|
|
setTimeout: (callback) => {
|
||
|
|
fireTimeout = callback;
|
||
|
|
return 1;
|
||
|
|
},
|
||
|
|
clearTimeout: () => {},
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
});
|
||
|
|
|
||
|
|
const pending = api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
fireTimeout();
|
||
|
|
const result = await pending;
|
||
|
|
const lateStream = new EventEmitter();
|
||
|
|
lateStream.stderr = new EventEmitter();
|
||
|
|
lateStream.close = () => { closeCalls += 1; };
|
||
|
|
execCallback(null, lateStream);
|
||
|
|
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
assert.equal(closeCalls, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("three stats retries on an unresponsive exec open leave no pooled transport or channel callbacks", async () => {
|
||
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||
|
|
const endpoint = { hostId: "stats-host", hostname: "wedged.example", username: "root" };
|
||
|
|
const conn = new EventEmitter();
|
||
|
|
conn._sock = { destroyed: false };
|
||
|
|
conn.pendingChannelCallbacks = [];
|
||
|
|
conn.exec = (_command, callback) => {
|
||
|
|
if (conn._sock.destroyed) throw new Error("Not connected");
|
||
|
|
conn.pendingChannelCallbacks.push(callback);
|
||
|
|
};
|
||
|
|
conn.end = () => {};
|
||
|
|
conn.destroy = () => {
|
||
|
|
if (conn._sock.destroyed) return;
|
||
|
|
conn._sock.destroyed = true;
|
||
|
|
conn.pendingChannelCallbacks.length = 0;
|
||
|
|
conn.emit("close");
|
||
|
|
};
|
||
|
|
const transport = createTransport({ conn, endpoint });
|
||
|
|
borrowTransport(transport, { kind: "shell", holder: {} });
|
||
|
|
|
||
|
|
const timers = [];
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
sessions: new Map([["sid", { type: "ssh", conn }]]),
|
||
|
|
Date,
|
||
|
|
setTimeout: (callback) => {
|
||
|
|
const timer = { callback, cleared: false, unref() {} };
|
||
|
|
timers.push(timer);
|
||
|
|
return timer;
|
||
|
|
},
|
||
|
|
clearTimeout: (timer) => { if (timer) timer.cleared = true; },
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
});
|
||
|
|
|
||
|
|
const first = api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
const openingTimer = timers.find((timer) => !timer.cleared);
|
||
|
|
assert.ok(openingTimer);
|
||
|
|
openingTimer.callback();
|
||
|
|
const results = [await first];
|
||
|
|
results.push(await api.getServerStats({ sender: {} }, { sessionId: "sid" }));
|
||
|
|
results.push(await api.getServerStats({ sender: {} }, { sessionId: "sid" }));
|
||
|
|
|
||
|
|
assert.ok(results.every((result) => result.success === false));
|
||
|
|
assert.equal(conn.pendingChannelCallbacks.length, 0);
|
||
|
|
assert.equal(getTransportStats().transports, 0);
|
||
|
|
assert.equal(findTransportByEndpoint(endpoint), null);
|
||
|
|
|
||
|
|
const replacement = new EventEmitter();
|
||
|
|
replacement._sock = { destroyed: false };
|
||
|
|
replacement.end = () => {};
|
||
|
|
const replacementTransport = createTransport({ conn: replacement, endpoint });
|
||
|
|
assert.equal(findTransportByEndpoint(endpoint), replacementTransport);
|
||
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats includes host identity, load average, and uptime", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = {
|
||
|
|
type: "ssh",
|
||
|
|
conn: fakeConn(
|
||
|
|
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:/:20:80:25|NET:eth0:1000:2000|HOST:demo-box|OS:Ubuntu 24.04 LTS|KERNEL:6.8.0|UPTIME:12345|LOAD:0.10 0.20 0.30",
|
||
|
|
),
|
||
|
|
};
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(result.stats.hostname, "demo-box");
|
||
|
|
assert.equal(result.stats.osName, "Ubuntu 24.04 LTS");
|
||
|
|
assert.equal(result.stats.kernelRelease, "6.8.0");
|
||
|
|
assert.equal(result.stats.uptimeSeconds, 12345);
|
||
|
|
assert.deepEqual(result.stats.loadAverage, [0.1, 0.2, 0.3]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats derives disk percent when the Capacity field is non-numeric", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
conn: fakeConn(
|
||
|
|
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:/:1:8:-:rpool/data/subvol-101-disk-0|NET:",
|
||
|
|
),
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.deepEqual(result.stats.disks, [
|
||
|
|
{ mountPoint: "/", used: 1, total: 8, percent: 13, capacityKey: "rpool/data/subvol-101-disk-0" },
|
||
|
|
]);
|
||
|
|
assert.equal(result.stats.diskPercent, 13);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats keeps blank load average and uptime as missing data", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const session = {
|
||
|
|
type: "ssh",
|
||
|
|
conn: fakeConn(
|
||
|
|
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:|UPTIME:|LOAD:",
|
||
|
|
),
|
||
|
|
};
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(result.stats.uptimeSeconds, null);
|
||
|
|
assert.deepEqual(result.stats.loadAverage, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats parses macOS stats and avoids blocking top command", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
let command = "";
|
||
|
|
const session = {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "mac.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(cmd, cb) {
|
||
|
|
command = cmd;
|
||
|
|
cb(null, fakeStream(MACOS_STATS));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.match(command, /Darwin/);
|
||
|
|
assert.match(command, /ps -A -o %cpu=/);
|
||
|
|
assert.match(command, /awk -v c="\$cores"/);
|
||
|
|
assert.match(command, /s=s\/c/);
|
||
|
|
assert.match(command, /u=\(\$2-\$4\)\/1048576/);
|
||
|
|
assert.doesNotMatch(command, /top -l/);
|
||
|
|
assert.equal(result.stats.cpu, 27);
|
||
|
|
assert.equal(result.stats.cpuCores, 10);
|
||
|
|
assert.equal(result.stats.memTotal, 32768);
|
||
|
|
assert.equal(result.stats.memUsed, 20480);
|
||
|
|
assert.equal(result.stats.diskPercent, 26);
|
||
|
|
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
|
||
|
|
assert.equal(result.stats.netInterfaces.length, 1);
|
||
|
|
assert.equal(result.stats.netInterfaces[0].name, "en0");
|
||
|
|
assert.equal(result.stats.netInterfaces[0].rxBytes, 1000);
|
||
|
|
assert.equal(result.stats.netInterfaces[0].txBytes, 3000);
|
||
|
|
assert.equal(typeof result.stats.latencyMs, "number");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats keeps every remote command within Dropbear's command limit", async () => {
|
||
|
|
const commands = [];
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "openwrt.example.test", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
commands.push(command);
|
||
|
|
const output = command.includes("CPURAW:")
|
||
|
|
? `NC_LATENCY_MARK|${LINUX_STATS}`
|
||
|
|
: command.includes("DISKS:")
|
||
|
|
? "DISKS:"
|
||
|
|
: "";
|
||
|
|
cb(null, fakeStream(output));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(commands.length, 2, "Linux base and disk stats must use separate commands");
|
||
|
|
for (const command of commands) {
|
||
|
|
assert.ok(
|
||
|
|
Buffer.byteLength(command, "utf8") <= 9000,
|
||
|
|
`remote command is ${Buffer.byteLength(command, "utf8")} bytes`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats settles when the split disk command fails", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
if (command.includes('echo "DISKS:$disks"')) {
|
||
|
|
cb(new Error("disk stats rejected"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cb(null, fakeStream(`NC_LATENCY_MARK|${LINUX_STATS}`));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await Promise.race([
|
||
|
|
api.getServerStats({ sender: {} }, { sessionId: "sid" }),
|
||
|
|
new Promise((resolve) => setTimeout(() => resolve({ timedOut: true }), 100)),
|
||
|
|
]);
|
||
|
|
|
||
|
|
assert.notEqual(result.timedOut, true);
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
assert.equal(result.error, "disk stats rejected");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats reports pending (not a hard failure) for a Mosh session before the handshake swap", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
// Connected (renderer polls) but moshStatsAuth not yet assigned.
|
||
|
|
const session = { type: "mosh" };
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
let ensureCalls = 0;
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
ensureMoshStatsConnection: async () => {
|
||
|
|
ensureCalls += 1;
|
||
|
|
return null; // nothing to connect with yet
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(ensureCalls, 1);
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
// pending must be set so the renderer doesn't count this toward give-up.
|
||
|
|
assert.equal(result.pending, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats reports a hard failure (not pending) once the companion permanently failed", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
// moshStatsAuth present but the companion has permanently failed (e.g. auth
|
||
|
|
// rejected) — this is a real failure, the renderer should be allowed to give
|
||
|
|
// up.
|
||
|
|
const session = { type: "mosh", moshStatsAuth: { hostname: "h" }, moshStatsConnFailed: true };
|
||
|
|
sessions.set("sid", session);
|
||
|
|
|
||
|
|
const api = createSessionOpsApi({
|
||
|
|
get sessions() {
|
||
|
|
return sessions;
|
||
|
|
},
|
||
|
|
setTimeout,
|
||
|
|
clearTimeout,
|
||
|
|
Buffer,
|
||
|
|
quoteShellArg,
|
||
|
|
ensureMoshStatsConnection: async () => null,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
assert.notEqual(result.pending, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats returns an error for an unknown session", async () => {
|
||
|
|
const sessions = new Map();
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "missing" });
|
||
|
|
|
||
|
|
assert.equal(result.success, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getServerStats wraps probes in a remote watchdog matching the client timeout", async () => {
|
||
|
|
const commands = [];
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
_reuseEndpoint: { hostname: "slow.example", port: 22 },
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
commands.push(command);
|
||
|
|
cb(null, fakeStream(LINUX_STATS));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(commands.length, 2);
|
||
|
|
for (const command of commands) {
|
||
|
|
// The watchdog must be armed before the probe and killed after it, with
|
||
|
|
// the same 10s bound the client enforces, so an abandoned probe cannot
|
||
|
|
// linger and burn CPU on the remote host (#3187). It must also kill the
|
||
|
|
// probe's whole descendant tree: a probe blocked in an external child
|
||
|
|
// (df, ps, lsof, ...) would otherwise survive `kill -9 $$` and keep
|
||
|
|
// holding the channel's output descriptors.
|
||
|
|
assert.ok(
|
||
|
|
command.startsWith('exec sh -c '),
|
||
|
|
`missing POSIX sh wrap: ${command.slice(0, 120)}`,
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
command.includes('( sleep 10 &&'),
|
||
|
|
`missing watchdog arm: ${command.slice(0, 160)}`,
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
command.includes('kill -9 $nc_tree "$$"'),
|
||
|
|
"watchdog must kill the probe's descendant tree, not only the shell",
|
||
|
|
);
|
||
|
|
// The watchdog's stdio must be detached from the channel so its `sleep`
|
||
|
|
// child cannot hold the channel's output descriptors open.
|
||
|
|
assert.ok(
|
||
|
|
command.includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"),
|
||
|
|
"watchdog subshell must not inherit the channel's stdio",
|
||
|
|
);
|
||
|
|
// Cleanup must reap the watchdog's pending `sleep` (before killing the
|
||
|
|
// subshell, while the PPID walk can still find it): killing only the
|
||
|
|
// subshell would leave the `sleep` alive for the full watchdog duration,
|
||
|
|
// delaying the channel close and racing the client-side timeout.
|
||
|
|
assert.ok(
|
||
|
|
command.includes('kill -9 $nc_kids "$nc_watchdog_pid"'),
|
||
|
|
"watchdog cleanup must reap the watchdog's pending sleep child",
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
command.includes("exit $nc_status"),
|
||
|
|
"watchdog cleanup must preserve the probe exit status",
|
||
|
|
);
|
||
|
|
assert.ok(command.includes("( sleep 10 &&"), "watchdog bound must match the 10s stats run timeout");
|
||
|
|
}
|
||
|
|
assert.ok(commands[0].includes("NC_LATENCY_MARK"));
|
||
|
|
assert.ok(commands[1].includes('echo "DISKS:$disks"'));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getSessionDistroInfo wraps the os-release probe in a remote watchdog", async () => {
|
||
|
|
const commands = [];
|
||
|
|
const sessions = new Map();
|
||
|
|
sessions.set("sid", {
|
||
|
|
type: "ssh",
|
||
|
|
conn: {
|
||
|
|
exec(command, cb) {
|
||
|
|
commands.push(command);
|
||
|
|
cb(null, fakeStream('NAME="UnionTech OS Server 20"\nID=uos'));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const api = makeSessionOps(sessions);
|
||
|
|
const result = await api.getSessionDistroInfo({ sender: {} }, { sessionId: "sid" });
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(commands.length, 1);
|
||
|
|
assert.ok(
|
||
|
|
commands[0].startsWith('exec sh -c '),
|
||
|
|
"distro probe must force POSIX sh so fish/zsh login shells can parse it",
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
commands[0].includes('( sleep 5 &&'),
|
||
|
|
"distro probe must carry a 5s remote watchdog",
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
commands[0].includes('kill -9 $nc_tree "$$"'),
|
||
|
|
"distro watchdog must kill the probe's descendant tree, not only the shell",
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
commands[0].includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"),
|
||
|
|
"distro watchdog subshell must not inherit the channel's stdio",
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
commands[0].includes('kill -9 $nc_kids "$nc_watchdog_pid"'),
|
||
|
|
"distro watchdog cleanup must reap the watchdog's pending sleep child",
|
||
|
|
);
|
||
|
|
assert.ok(commands[0].includes("cat /etc/os-release"));
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const blocked of [false, true]) {
|
||
|
|
test(`real distro probe ${blocked ? 'kills blocked children' : 'cleans its watchdog after success'}`, {
|
||
|
|
skip: process.platform === 'win32', timeout: 10000,
|
||
|
|
}, async () => {
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
const { spawn } = require('node:child_process');
|
||
|
|
const directory = require('../tempDirBridge.cjs').getTempFilePath('watchdog-test');
|
||
|
|
fs.mkdirSync(directory, { mode: 0o700 });
|
||
|
|
fs.writeFileSync(path.join(directory, 'cat'), blocked
|
||
|
|
? '#!/bin/sh\nexec /bin/sleep 30\n'
|
||
|
|
: '#!/bin/sh\nprintf probe_completed\n', { mode: 0o700 });
|
||
|
|
const commands = [];
|
||
|
|
const api = makeSessionOps(new Map([['watchdog-smoke', {
|
||
|
|
type: 'ssh', conn: { exec(command, cb) { commands.push(command); cb(null, fakeStream('fixture')); } },
|
||
|
|
}]]));
|
||
|
|
const captured = await api.getSessionDistroInfo({}, { sessionId: 'watchdog-smoke' });
|
||
|
|
assert.equal(captured.success, true);
|
||
|
|
assert.equal(commands.length, 1);
|
||
|
|
const started = Date.now();
|
||
|
|
const child = spawn('/bin/sh', ['-c', commands[0]], {
|
||
|
|
detached: true, env: { ...process.env, PATH: `${directory}:${process.env.PATH}` },
|
||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||
|
|
});
|
||
|
|
let output = '', outcome = null;
|
||
|
|
child.stdout.on('data', data => { output += data; });
|
||
|
|
child.stderr.resume();
|
||
|
|
const finished = new Promise((resolve, reject) => {
|
||
|
|
child.once('error', reject);
|
||
|
|
child.once('close', (code, signal) => { outcome = { code, signal }; resolve(); });
|
||
|
|
});
|
||
|
|
let timer;
|
||
|
|
try {
|
||
|
|
await Promise.race([finished, new Promise(resolve => { timer = setTimeout(resolve, 7000); })]);
|
||
|
|
const ps = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { encoding: 'utf8' });
|
||
|
|
assert.equal(ps.status, 0, ps.stderr);
|
||
|
|
const living = ps.stdout.split('\n').map(line => line.trim().split(/\s+/))
|
||
|
|
.filter(row => Number(row[1]) === child.pid && !row[2].startsWith('Z'));
|
||
|
|
assert.ok(outcome, `probe did not close; descendants still alive: ${JSON.stringify(living)}`);
|
||
|
|
assert.deepEqual(living, [], 'probe must not leave a child or watchdog sleep running');
|
||
|
|
if (blocked) {
|
||
|
|
assert.equal(outcome.signal, 'SIGKILL');
|
||
|
|
assert.ok(Date.now() - started >= 4500, 'remote watchdog must own the timeout');
|
||
|
|
} else {
|
||
|
|
assert.equal(outcome.code, 0);
|
||
|
|
assert.equal(output, 'probe_completed');
|
||
|
|
assert.ok(Date.now() - started < 2000, 'success must not wait for the watchdog deadline');
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
clearTimeout(timer);
|
||
|
|
try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
||
|
|
await finished;
|
||
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|