Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1801 lines
87 KiB
JavaScript
1801 lines
87 KiB
JavaScript
/* eslint-disable no-undef */
|
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
|
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
|
function decodeLsofFileName(value) {
|
|
if (typeof value !== 'string') return null;
|
|
// lsof's caret form is ambiguous: a BEL byte and the literal characters
|
|
// "^G" have the same output. Reject it instead of navigating to a guessed path.
|
|
if (/\^[\x40-\x5f?]/.test(value)) return null;
|
|
const bytes = [];
|
|
const simpleEscapes = {
|
|
b: 0x08,
|
|
f: 0x0c,
|
|
n: 0x0a,
|
|
r: 0x0d,
|
|
t: 0x09,
|
|
v: 0x0b,
|
|
'\\': 0x5c,
|
|
};
|
|
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const char = value[index];
|
|
if (char !== '\\') {
|
|
bytes.push(...Buffer.from(char));
|
|
continue;
|
|
}
|
|
const escaped = value[index + 1];
|
|
if (!escaped) return null;
|
|
if (Object.prototype.hasOwnProperty.call(simpleEscapes, escaped)) {
|
|
bytes.push(simpleEscapes[escaped]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (escaped === 'x') {
|
|
const hex = value.slice(index + 2, index + 4);
|
|
if (!/^[0-9a-fA-F]{2}$/.test(hex)) return null;
|
|
bytes.push(Number.parseInt(hex, 16));
|
|
index += 3;
|
|
continue;
|
|
}
|
|
const octal = value.slice(index + 1).match(/^[0-7]{1,3}/)?.[0];
|
|
if (octal) {
|
|
bytes.push(Number.parseInt(octal, 8));
|
|
index += octal.length;
|
|
continue;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrap a one-shot remote probe so it cannot outlive the client-side timeout.
|
|
*
|
|
* If the renderer or transport abandons an exec (client-side run timeout,
|
|
* window close, teardown), sshd can keep the remote shell alive — on weak
|
|
* hosts these orphaned probe shells linger and burn CPU until someone kills
|
|
* them by hand (#3187). A watchdog subshell SIGKILLs the probe shell once the
|
|
* same bound the client enforces has elapsed, so an abandoned probe always
|
|
* terminates remotely even when the server never reacts to the channel close.
|
|
*
|
|
* POSIX-safe: `$$` inside a subshell still refers to the parent shell on
|
|
* bash/dash/ash, so the watchdog knows the probe shell's PID. Killing only
|
|
* `$$` is not enough: a probe blocked in an external child (`df`, `ps`,
|
|
* `lsof`, `cat`, ...) leaves that child orphaned, still holding the channel's
|
|
* output descriptors. The watchdog therefore also SIGKILLs the probe shell's
|
|
* whole descendant tree (found via a PPID walk over `ps`, excluding the
|
|
* watchdog's own lineage) before killing the probe shell itself. On hosts
|
|
* without a usable `ps` the watchdog degrades to the old self-kill. The
|
|
* subshell's stdio is detached from the channel so nothing it forks (notably
|
|
* its `sleep`) can hold the channel's output descriptors open. The trailing
|
|
* cleanup runs on the success path and must also kill the watchdog's pending
|
|
* `sleep` child: killing only the subshell would leave that `sleep` alive
|
|
* until the full watchdog duration (which equals the client-side timeout),
|
|
* and while it lived it would hold the channel's descriptors, delaying the
|
|
* channel close until then — turning successful probes into timeouts.
|
|
* Children are enumerated via a PPID walk *before* the subshell is killed;
|
|
* once it dies they are reparented to init and unreachable. After a watchdog
|
|
* kill the main shell is SIGKILLed before reaching the cleanup, so the
|
|
* already-finished watchdog leaves no orphaned `sleep` behind.
|
|
*/
|
|
function withRemoteWatchdog(command, timeoutMs) {
|
|
const seconds = Math.max(1, Math.ceil((Number(timeoutMs) || 10000) / 1000));
|
|
const descendantTreeAwk =
|
|
'{ pp[$1+0]=$2+0 } END { for (p in pp) { q=p+0; d=0; while (q>0 && d<4096) { if (q==root) { printf "%s ", p+0; break } if (q==self) break; q=pp[q]+0; d++ } } }';
|
|
const watchdog = [
|
|
`( sleep ${seconds}`,
|
|
// Resolve the watchdog subshell's own PID so the tree walk can skip it
|
|
// (killing ourselves mid-list would abort the remaining kills).
|
|
// exec replaces the command-substitution shell: without it Bash may
|
|
// report that intermediate PID instead of the watchdog subshell.
|
|
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
|
|
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
|
|
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
|
|
].join(' ');
|
|
return [
|
|
watchdog,
|
|
command,
|
|
'nc_status=$?',
|
|
// Reap the watchdog's pending `sleep` before killing the subshell: after
|
|
// the subshell is SIGKILLed its children are reparented to init and a
|
|
// PPID walk can no longer find them.
|
|
'nc_kids=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
|
|
descendantTreeAwk +
|
|
"')",
|
|
'kill -9 $nc_kids "$nc_watchdog_pid" 2>/dev/null',
|
|
'exit $nc_status',
|
|
].join('; ');
|
|
}
|
|
|
|
function createSessionOpsApi(ctx) {
|
|
with (ctx) {
|
|
const cwdRecoveryToken = Symbol('cwd-recovery');
|
|
function withPosixRemoteWatchdog(command, timeoutMs) {
|
|
// sshd invokes `$SHELL -c command`. fish/zsh cannot parse the POSIX watchdog.
|
|
// `exec sh -c` is accepted by fish and replaces the login shell so the
|
|
// POSIX sh's $PPID is sshd (needed by the cwd probe).
|
|
return `exec sh -c ${quoteShellArg(withRemoteWatchdog(command, timeoutMs))}`;
|
|
}
|
|
function getTcpLatencyTarget(session) {
|
|
if (session.tcpLatencyDirect === false) return null;
|
|
|
|
const auth = session.tcpLatencyTarget || session.moshStatsAuth || session.etStatsAuth || session._reuseEndpoint || null;
|
|
if (auth?.hasJumpHost || auth?.hasProxy) return null;
|
|
|
|
const hostname = auth?.hostname || session.hostname;
|
|
const rawPort = auth?.port ?? 22;
|
|
const port = Number(rawPort);
|
|
if (!hostname || !Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
return { hostname, port };
|
|
}
|
|
|
|
async function getSessionRemoteInfo(_event, payload) {
|
|
const { sessionId } = payload || {};
|
|
const session = sessions.get(sessionId);
|
|
if (!session) {
|
|
return { success: false, error: 'Session not found' };
|
|
}
|
|
return {
|
|
success: true,
|
|
remoteSshVersion: session.remoteSshVersion || '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run the distro-identification probe on an already-connected SSH
|
|
* session's connection. Uses an exec channel on the existing conn —
|
|
* which is still one extra channel (and therefore one extra AAA
|
|
* session on vendor CLIs that don't multiplex channels cleanly), but
|
|
* avoids the full auth round-trip that `execCommand` would do by
|
|
* creating a brand new SSHClient. The renderer only falls through to
|
|
* this when banner classification returned no vendor, so in practice
|
|
* it never runs against Cisco/Huawei/HPE/etc. — only against
|
|
* Linux-like hosts and OpenSSH-fronted network devices (JUNOS,
|
|
* NX-OS, EOS) that are already handled by the useServerStats
|
|
* failure-counter path downstream.
|
|
*/
|
|
async function getSessionDistroInfo(_event, payload) {
|
|
const { sessionId } = payload || {};
|
|
const session = sessions.get(sessionId);
|
|
if (session?.type === "et") {
|
|
if (typeof execOnEtSession !== "function") {
|
|
return { success: false, error: "ET command executor unavailable" };
|
|
}
|
|
return execOnEtSession(session, "cat /etc/os-release 2>/dev/null || uname -a", 5000, {
|
|
requireTrustedHost: true,
|
|
knownHosts: session.etStatsAuth?.knownHosts,
|
|
});
|
|
}
|
|
if (session?.type === 'mosh' && !session.moshStatsConn && typeof ensureMoshStatsConnection === 'function') {
|
|
try {
|
|
await ensureMoshStatsConnection(session, sessionId, _event?.sender);
|
|
} catch (err) {
|
|
return { success: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
const conn = session?.conn || session?.moshStatsConn;
|
|
if (!conn) {
|
|
return { success: false, error: 'Session not found or not connected' };
|
|
}
|
|
const command = withPosixRemoteWatchdog(
|
|
"cat /etc/os-release 2>/dev/null || uname -a",
|
|
5000,
|
|
);
|
|
try {
|
|
const { stdout, stderr } = await executeBoundedSshCommand(conn, command, {
|
|
openingTimeoutMs: 5000,
|
|
runTimeoutMs: 5000,
|
|
maxOutputBytes: 256 * 1024,
|
|
});
|
|
return { success: true, stdout, stderr };
|
|
} catch (err) {
|
|
return { success: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
|
|
async function readRemoteHistory(event, payload) {
|
|
const { sessionId, limit } = payload || {};
|
|
const session = sessions.get(sessionId);
|
|
if (!session) {
|
|
return { success: false, error: 'Session not found' };
|
|
}
|
|
|
|
const safeLimit =
|
|
Number.isFinite(limit) && limit > 0 && limit <= 10000 ? Math.floor(limit) : 1000;
|
|
const fishLimit = safeLimit * 3; // fish records span 2-3 lines each
|
|
|
|
const SHELL_MARKER = '__NC_SHELL__';
|
|
const BASH_MARKER = '__NC_BASH__';
|
|
const ZSH_MARKER = '__NC_ZSH__';
|
|
const FISH_MARKER = '__NC_FISH__';
|
|
// Shell parameter expansions kept as literal text so the remote POSIX
|
|
// shell (not Node or the user's login shell) resolves them; honours
|
|
// $HISTFILE / $XDG_DATA_HOME overrides.
|
|
const ZSH_HIST = '${HISTFILE:-$HOME/.zsh_history}';
|
|
const BASH_HIST = '${HISTFILE:-$HOME/.bash_history}';
|
|
const FISH_PATH = '${XDG_DATA_HOME:-$HOME/.local/share}/fish/fish_history';
|
|
const posixScript = [
|
|
`SH="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"; [ -n "$SH" ] || SH="$SHELL"`,
|
|
`FISH="${FISH_PATH}"; [ -f "$FISH" ] || FISH="$HOME/.config/fish/fish_history"`,
|
|
`case "$SH" in`,
|
|
` *zsh) printf '%s\\n' '${SHELL_MARKER}zsh'; printf '%s\\n' '${ZSH_MARKER}'; tail -n ${safeLimit} "${ZSH_HIST}" 2>/dev/null || true ;;`,
|
|
` *bash) printf '%s\\n' '${SHELL_MARKER}bash'; printf '%s\\n' '${BASH_MARKER}'; tail -n ${safeLimit} "${BASH_HIST}" 2>/dev/null || true ;;`,
|
|
` *fish) printf '%s\\n' '${SHELL_MARKER}fish'; printf '%s\\n' '${FISH_MARKER}'; tail -n ${fishLimit} "$FISH" 2>/dev/null || true ;;`,
|
|
` *) printf '%s\\n' '${SHELL_MARKER}unknown'; printf '%s\\n' '${BASH_MARKER}'; tail -n ${safeLimit} "$HOME/.bash_history" 2>/dev/null || true; printf '%s\\n' '${ZSH_MARKER}'; tail -n ${safeLimit} "$HOME/.zsh_history" 2>/dev/null || true; printf '%s\\n' '${FISH_MARKER}'; tail -n ${fishLimit} "$FISH" 2>/dev/null || true ;;`,
|
|
`esac`,
|
|
].join('\n');
|
|
// Match getSessionPwd: force POSIX sh so fish/zsh login shells do not
|
|
// parse the script themselves.
|
|
const command = `exec sh -c ${quoteShellArg(posixScript)}`;
|
|
|
|
const parse = (stdout) => {
|
|
const text = stdout || '';
|
|
let shell = 'unknown';
|
|
const shellIdx = text.indexOf(SHELL_MARKER);
|
|
if (shellIdx >= 0) {
|
|
const after = text.slice(shellIdx + SHELL_MARKER.length);
|
|
shell = (after.split(/\r?\n/, 1)[0] || 'unknown').trim();
|
|
}
|
|
const section = (marker) => {
|
|
const start = text.indexOf(marker);
|
|
if (start < 0) return '';
|
|
const from = start + marker.length;
|
|
// End at the nearest following section marker, if any.
|
|
const ends = [BASH_MARKER, ZSH_MARKER, FISH_MARKER]
|
|
.map((m) => text.indexOf(m, from))
|
|
.filter((i) => i >= 0);
|
|
const end = ends.length ? Math.min(...ends) : text.length;
|
|
return text.slice(from, end).replace(/^\r?\n/, '').replace(/\r?\n\s*$/, '');
|
|
};
|
|
return {
|
|
shell,
|
|
bash: section(BASH_MARKER),
|
|
zsh: section(ZSH_MARKER),
|
|
fish: section(FISH_MARKER),
|
|
};
|
|
};
|
|
|
|
// ET session: no ssh2 conn — run through the shared system-ssh executor.
|
|
if (session.type === 'et') {
|
|
if (typeof execOnEtSession !== 'function') {
|
|
return { success: false, error: 'ET command executor unavailable' };
|
|
}
|
|
const result = await execOnEtSession(session, command, 8000);
|
|
if (!result || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: (result && result.error) || 'Failed to read remote history',
|
|
};
|
|
}
|
|
return { success: true, ...parse(result.stdout || '') };
|
|
}
|
|
|
|
// Mosh has no interactive ssh2 conn of its own. Lazily open the same
|
|
// stats companion getServerStats uses — it lives on session.moshStatsConn,
|
|
// never session.conn (see moshStatsConnection.cjs). Reading the fixed
|
|
// history files is connection-agnostic, so the companion is a safe
|
|
// substitute here (unlike getSessionPwd, which needs the interactive
|
|
// shell's sibling exec channel).
|
|
if (
|
|
!session.conn &&
|
|
!session.moshStatsConn &&
|
|
session.type === 'mosh' &&
|
|
typeof ensureMoshStatsConnection === 'function'
|
|
) {
|
|
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
|
}
|
|
|
|
const conn = session.conn || session.moshStatsConn;
|
|
if (!conn) {
|
|
// A Mosh session can be marked "connected" before the handshake stores
|
|
// credentials; report pending (mirrors getServerStats) so it isn't a
|
|
// hard failure during that window.
|
|
if (session.type === 'mosh' && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
|
return { success: false, pending: true, error: 'Mosh handshake in progress' };
|
|
}
|
|
return { success: false, error: 'Session not found or not connected' };
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await executeBoundedSshCommand(conn, command, {
|
|
openingTimeoutMs: 8000,
|
|
runTimeoutMs: 8000,
|
|
maxOutputBytes: 10 * 1024 * 1024,
|
|
});
|
|
return { success: true, ...parse(stdout) };
|
|
} catch (err) {
|
|
return { success: false, error: err?.message || String(err) };
|
|
}
|
|
}
|
|
|
|
async function getSessionPwd(event, payload) {
|
|
const { sessionId } = payload;
|
|
const isTargetedRecovery = payload?._cwdRecoveryToken === cwdRecoveryToken;
|
|
const allowHomeFallback = payload?.allowHomeFallback !== false;
|
|
// Login-shell fallback defaults to the same gate as ~ guessing so callers
|
|
// that pass allowHomeFallback: false (e.g. captureInheritedCwd) keep the
|
|
// pre-#2886 failure path and can fall through to lastCwd. SFTP fresh-cwd
|
|
// probes opt in explicitly with allowLoginShellFallback: true.
|
|
const allowLoginShellFallback = payload?.allowLoginShellFallback ?? allowHomeFallback;
|
|
const requestedTimeoutMs = Number(payload?.timeoutMs);
|
|
const timeoutMs = Number.isFinite(requestedTimeoutMs)
|
|
? Math.min(Math.max(requestedTimeoutMs, 100), 5000)
|
|
: 5000;
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session || !session.conn) {
|
|
return { success: false, error: 'Session not found or not connected' };
|
|
}
|
|
if (
|
|
session.blockUntargetedCwdProbe
|
|
&& session.cwdRecoveryPromise
|
|
&& !isTargetedRecovery
|
|
) {
|
|
return session.cwdRecoveryPromise;
|
|
}
|
|
if (session.blockUntargetedCwdProbe && !session.shellPid && !isTargetedRecovery) {
|
|
const transport = session.connRef || null;
|
|
if (session.cwdRecoveryPromise) return session.cwdRecoveryPromise;
|
|
if (transport?.cwdRecoveryPromise) {
|
|
await transport.cwdRecoveryPromise.catch(() => {});
|
|
return { success: false, error: 'Another cwd recovery was already in progress' };
|
|
}
|
|
if (session.allowCwdRecovery !== true || transport?.cwdRecoveryDisabled) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory is unavailable during an immediate reconnect',
|
|
};
|
|
}
|
|
const reconnectRisk = session.parkedReconnectRisk;
|
|
if (!reconnectRisk || reconnectRisk.hasUnknownOldShell) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory cannot be safely identified after reconnect',
|
|
};
|
|
}
|
|
// One real command/output pair permits one attempt. Ambiguous or failed
|
|
// recovery stays closed until another command produces output.
|
|
session.allowCwdRecovery = false;
|
|
const recoveryOwner = {
|
|
conn: session.conn,
|
|
connRef: session.connRef,
|
|
stream: session.stream,
|
|
shellCloseGeneration: transport?.shellCloseGeneration || 0,
|
|
};
|
|
const isCurrentOwner = () => (
|
|
sessions.get(sessionId) === session
|
|
&& session.conn === recoveryOwner.conn
|
|
&& session.connRef === recoveryOwner.connRef
|
|
&& session.stream === recoveryOwner.stream
|
|
);
|
|
const recoveryPromise = (async () => {
|
|
const sharedTerminalCountBeforeRecovery = transport
|
|
? [...sessions.values()].filter(
|
|
(candidate) => candidate?.connRef === transport && candidate?.stream,
|
|
).length
|
|
: 1;
|
|
if (sharedTerminalCountBeforeRecovery !== 1) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory is ambiguous across shared terminal channels',
|
|
};
|
|
}
|
|
if (transport?.closedShellPidUnknown) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory cannot be safely identified after reconnect',
|
|
};
|
|
}
|
|
const oldShellPids = new Set([
|
|
...(Array.isArray(reconnectRisk.oldShellPids) ? reconnectRisk.oldShellPids : []),
|
|
...(
|
|
transport?.closedShellPids instanceof Set
|
|
? [...transport.closedShellPids]
|
|
: []
|
|
),
|
|
].map(String));
|
|
const discovery = await listInteractiveShellPids(session.conn, {
|
|
quoteShellArg,
|
|
openingTimeoutMs: timeoutMs,
|
|
runTimeoutMs: timeoutMs,
|
|
setTimeoutFn: setTimeout,
|
|
clearTimeoutFn: clearTimeout,
|
|
// Best-effort cwd recovery must not kill the interactive shell or
|
|
// other SFTP/forward leases when a server never answers exec open.
|
|
invalidateOnOpenTimeout: false,
|
|
});
|
|
if (discovery.openTimedOut && transport) transport.cwdRecoveryDisabled = true;
|
|
if (!isCurrentOwner()) {
|
|
return { success: false, error: 'Session changed during cwd recovery' };
|
|
}
|
|
if (
|
|
transport
|
|
&& (transport.shellCloseGeneration || 0) !== recoveryOwner.shellCloseGeneration
|
|
) {
|
|
return { success: false, error: 'Terminal set changed during cwd recovery' };
|
|
}
|
|
if (transport?.closedShellPidUnknown) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory cannot be safely identified after reconnect',
|
|
};
|
|
}
|
|
const assignedSiblingPids = new Set(
|
|
[...sessions.values()]
|
|
.filter((candidate) => (
|
|
candidate?.connRef === session.connRef
|
|
&& candidate !== session
|
|
&& candidate.shellPid
|
|
))
|
|
.map((candidate) => String(candidate.shellPid)),
|
|
);
|
|
const unclaimedPids = discovery.pids.filter(
|
|
(pid) => (
|
|
!assignedSiblingPids.has(String(pid))
|
|
&& !oldShellPids.has(String(pid))
|
|
),
|
|
);
|
|
const sharedTerminalCountAfterRecovery = session.connRef
|
|
? [...sessions.values()].filter(
|
|
(candidate) => candidate?.connRef === session.connRef && candidate?.stream,
|
|
).length
|
|
: 1;
|
|
if (
|
|
sharedTerminalCountAfterRecovery !== 1
|
|
|| !discovery.available
|
|
|| unclaimedPids.length !== 1
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory is still ambiguous after reconnect',
|
|
};
|
|
}
|
|
const recoveredPid = unclaimedPids[0];
|
|
const result = await getSessionPwd(event, {
|
|
...payload,
|
|
_cwdRecoveryToken: cwdRecoveryToken,
|
|
_cwdRecoveryTargetPid: recoveredPid,
|
|
_cwdRecoveryTransport: transport,
|
|
});
|
|
if (!isCurrentOwner()) {
|
|
return { success: false, error: 'Session changed during cwd recovery' };
|
|
}
|
|
const sharedTerminalCountAfterPwd = transport
|
|
? [...sessions.values()].filter(
|
|
(candidate) => candidate?.connRef === transport && candidate?.stream,
|
|
).length
|
|
: 1;
|
|
if (
|
|
sharedTerminalCountAfterPwd !== 1
|
|
|| (
|
|
transport
|
|
&& (transport.shellCloseGeneration || 0) !== recoveryOwner.shellCloseGeneration
|
|
)
|
|
|| transport?.closedShellPidUnknown
|
|
) {
|
|
return { success: false, error: 'Terminal set changed during cwd recovery' };
|
|
}
|
|
if (!result.success) {
|
|
return result;
|
|
}
|
|
session.shellPid = recoveredPid;
|
|
session.blockUntargetedCwdProbe = false;
|
|
session.parkedReconnectRisk = null;
|
|
return result;
|
|
})().finally(() => {
|
|
if (session.cwdRecoveryPromise === recoveryPromise) {
|
|
session.cwdRecoveryPromise = null;
|
|
}
|
|
if (transport?.cwdRecoveryPromise === recoveryPromise) {
|
|
transport.cwdRecoveryPromise = null;
|
|
}
|
|
});
|
|
session.cwdRecoveryPromise = recoveryPromise;
|
|
if (transport) transport.cwdRecoveryPromise = recoveryPromise;
|
|
return recoveryPromise;
|
|
}
|
|
|
|
const requestedRecoveryPid = isTargetedRecovery ? payload?._cwdRecoveryTargetPid : '';
|
|
const targetLoginPid = /^\d+$/.test(String(requestedRecoveryPid || session.shellPid || ''))
|
|
? String(requestedRecoveryPid || session.shellPid)
|
|
: '';
|
|
const sharedTerminalCount = session.connRef
|
|
? [...sessions.values()].filter(
|
|
(candidate) => candidate?.connRef === session.connRef && candidate?.stream,
|
|
).length
|
|
: 1;
|
|
if (sharedTerminalCount > 1 && !targetLoginPid) {
|
|
return {
|
|
success: false,
|
|
error: 'Current directory is ambiguous across shared terminal channels',
|
|
};
|
|
}
|
|
|
|
// Completely silent: uses a separate exec channel, nothing is printed
|
|
// in the interactive terminal. The exec channel and the interactive
|
|
// shell are both children of the same per-connection sshd process,
|
|
// so we find the shell as a sibling via $PPID.
|
|
const pwdOwner = {
|
|
conn: session.conn,
|
|
connRef: session.connRef,
|
|
stream: session.stream,
|
|
};
|
|
const isCurrentPwdOwner = () => (
|
|
sessions.get(sessionId) === session
|
|
&& session.conn === pwdOwner.conn
|
|
&& session.connRef === pwdOwner.connRef
|
|
&& session.stream === pwdOwner.stream
|
|
);
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
// POSIX sh script that:
|
|
// 1. Finds the user's interactive shell on the same SSH connection
|
|
// (sibling under $PPID on newer OpenSSH, cousin reachable via the
|
|
// shared SSH_CONNECTION env var on older OpenSSH like CentOS 7).
|
|
// 2. Follows foreground child shells only, which covers bash->fish
|
|
// without mistaking background shell scripts for the active shell.
|
|
// 3. Reads /proc/<pid>/cwd via readlink.
|
|
// 4. If the active (su'd) shell cwd is unreadable, falls back to the
|
|
// same-uid login shell cwd when allowed.
|
|
// 5. Falls back to the user's home directory if the caller allows it.
|
|
//
|
|
// Outer `exec sh -c` replaces the login shell (fish/zsh/...) so the
|
|
// POSIX sh's $PPID is sshd. That pid is exported as NC_SSHD_PPID
|
|
// because the watchdog wrapper would otherwise become $PPID of the
|
|
// inner posixScript (and macOS/BSD have no /proc fallback).
|
|
const posixScript = `SELF=$$
|
|
TARGET_LOGIN=${targetLoginPid}
|
|
ALLOW_HOME_FALLBACK=${allowHomeFallback ? "1" : "0"}
|
|
ALLOW_LOGIN_FALLBACK=${allowLoginShellFallback ? "1" : "0"}
|
|
# Find the user's interactive shell on this SSH connection.
|
|
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
|
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
|
# without the tty preference a concurrent probe's shell could be picked when
|
|
# several exist under the same sshd (#1065 review). Falls back to any shell
|
|
# child if none has a tty.
|
|
#
|
|
# Strategy: try direct siblings of $PPID first — works on newer OpenSSH where
|
|
# the PTY session and this exec channel share the same per-connection sshd
|
|
# parent. Fall back to matching by SSH_CONNECTION env var, which covers older
|
|
# OpenSSH (e.g. CentOS 7 / RHEL 7) that forks a SEPARATE sshd child per
|
|
# channel — there the PTY shell ends up as a cousin (same grandparent sshd,
|
|
# different parent) of this exec session, so the sibling search misses it
|
|
# entirely (#1123).
|
|
find_login_shell() {
|
|
_shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
|
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
|
|
$1 != self && $2 == pp && isshell($4) {
|
|
if ($3 !~ /^\\?+$/) { print $1; found=1; exit }
|
|
if (any == "") any=$1
|
|
}
|
|
END { if (!found && any != "") print any }
|
|
')
|
|
[ -n "$_shell" ] && { echo "$_shell"; return; }
|
|
# SSH_CONNECTION is the unique client-port/server-port 4-tuple sshd injects
|
|
# into every channel of one SSH connection, so processes that share it are
|
|
# the channels of this very connection — and exactly one of them is the
|
|
# user's PTY shell. Read /proc/<pid>/environ (NUL-separated, same uid only)
|
|
# to find candidates, then pick the one with a shell comm and a controlling
|
|
# tty. /proc/<pid>/comm is read directly here because ps -p PID -o tty=,comm=
|
|
# gets misparsed on older procps (CentOS 7): the trailing ",comm=" is folded
|
|
# into the tty column header instead of starting a second column, so tty and
|
|
# comm come back swapped.
|
|
_conn=$(tr '\\0' '\\n' < /proc/$SELF/environ 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
|
|
[ -z "$_conn" ] && return
|
|
_any=""
|
|
for _d in /proc/[0-9]*; do
|
|
_pid=$(basename "$_d")
|
|
[ "$_pid" = "$SELF" ] && continue
|
|
[ -r "$_d/environ" ] || continue
|
|
_conn2=$(tr '\\0' '\\n' < "$_d/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
|
|
[ "$_conn2" = "$_conn" ] || continue
|
|
_comm=$(cat "$_d/comm" 2>/dev/null)
|
|
case "$_comm" in
|
|
sh|bash|zsh|fish|ksh|dash|ash|csh|tcsh) ;;
|
|
*) continue ;;
|
|
esac
|
|
_tty=$(ps -p "$_pid" -o tty= 2>/dev/null | tr -d '[:space:]')
|
|
if [ "$_tty" != "?" ] && [ -n "$_tty" ]; then
|
|
echo "$_pid"
|
|
return
|
|
fi
|
|
[ -z "$_any" ] && _any="$_pid"
|
|
done
|
|
[ -n "$_any" ] && echo "$_any"
|
|
}
|
|
# From the login shell, pick the DEEPEST foreground shell in its process
|
|
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
|
# in stat), i.e. the shell the user is actually typing in. Walking the whole
|
|
# subtree (rather than only direct shell children) lets us follow through
|
|
# non-shell foreground parents like su / sudo, so we read the cwd of the
|
|
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
|
# back to the login shell when no foreground shell is found.
|
|
find_active_shell() {
|
|
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
|
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
|
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
|
|
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
|
END {
|
|
best=-1; bp="";
|
|
for (i=1; i<=NR; i++) {
|
|
p=ord[i];
|
|
if (!isshell(cm[p])) continue;
|
|
if (index(st[p], "+") == 0) continue;
|
|
d=depth(p); if (d < 0) continue;
|
|
if (d > best) { best=d; bp=p }
|
|
}
|
|
print (bp != "" ? bp : start)
|
|
}
|
|
'
|
|
}
|
|
# Read a process's cwd. Linux exposes it via /proc; systems without a
|
|
# readable /proc fall back to lsof so the SFTP follow-terminal probe also
|
|
# works on macOS and on BSD hosts that provide lsof (#2335). Both paths
|
|
# only see same-uid processes, so a su'd / sudo'd shell owned by another
|
|
# user stays unreadable here (handled by the login-shell fallback below).
|
|
read_shell_cwd() {
|
|
_rc_cwd=$(readlink "/proc/$1/cwd" 2>/dev/null)
|
|
if [ -n "$_rc_cwd" ]; then printf '%s\\n' "$_rc_cwd"; return 0; fi
|
|
if command -v lsof >/dev/null 2>&1; then
|
|
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n1)
|
|
if [ -n "$_rc_cwd" ]; then printf 'NETCATTY_LSOF_CWD=%s\\n' "$_rc_cwd"; return 0; fi
|
|
fi
|
|
return 1
|
|
}
|
|
login="$TARGET_LOGIN"
|
|
[ -n "$login" ] || login=$(find_login_shell "\${NC_SSHD_PPID:-$PPID}")
|
|
if [ -n "$login" ]; then
|
|
printf 'NETCATTY_LOGIN_PID=%s\\n' "$login" >&2
|
|
pid=$(find_active_shell "$login")
|
|
[ -n "$pid" ] || pid="$login"
|
|
cwd=$(read_shell_cwd "$pid")
|
|
# The active shell's cwd is only readable for same-uid processes (ptrace
|
|
# perms on /proc, lsof permissions on macOS/BSD), so this unprivileged
|
|
# exec channel cannot read a su'd / sudo'd shell owned by another user.
|
|
# Fall back to the same-uid login shell's cwd before giving up to the
|
|
# home directory (#1065 review). Callers that need this after disabling
|
|
# ~ guessing (SFTP preferFreshBackend) must set ALLOW_LOGIN_FALLBACK (#2886).
|
|
if [ -z "$cwd" ] && [ "$pid" != "$login" ] && [ "$ALLOW_LOGIN_FALLBACK" = "1" ]; then
|
|
cwd=$(read_shell_cwd "$login")
|
|
fi
|
|
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
|
fi
|
|
[ "$ALLOW_HOME_FALLBACK" = "1" ] || exit 1
|
|
emit_home() {
|
|
case "$1" in
|
|
/*) printf '%s\\n' "$1"; exit 0 ;;
|
|
esac
|
|
}
|
|
home=$(eval echo "~" 2>/dev/null)
|
|
emit_home "$home"
|
|
uid=$(id -u 2>/dev/null)
|
|
if [ -n "$uid" ]; then
|
|
home=$(getent passwd "$uid" 2>/dev/null | awk -F: 'NR == 1 { print $6; exit }')
|
|
emit_home "$home"
|
|
home=$(awk -F: -v uid="$uid" '$3 == uid { print $6; exit }' /etc/passwd 2>/dev/null)
|
|
emit_home "$home"
|
|
fi
|
|
home=$(id -P 2>/dev/null | awk -F: 'NR == 1 { print $9; exit }')
|
|
emit_home "$home"
|
|
emit_home "$HOME"
|
|
exit 1`;
|
|
// Capture sshd's pid in the exec'd POSIX sh (`$PPID` is sshd because
|
|
// `exec` replaced the login shell). Do not `exec` the inner posixScript:
|
|
// that would skip the watchdog cleanup after it.
|
|
const cmd = `exec sh -c ${quoteShellArg(
|
|
`export NC_SSHD_PPID=$PPID; ${withRemoteWatchdog(
|
|
`sh -c ${quoteShellArg(posixScript)}`,
|
|
timeoutMs,
|
|
)}`
|
|
)}`;
|
|
|
|
void executeBoundedSshCommand(session.conn, cmd, {
|
|
// Do not shorten channel opening: a timeout there invalidates the
|
|
// shared SSH transport. Only bound the best-effort command itself.
|
|
openingTimeoutMs: 5000,
|
|
runTimeoutMs: timeoutMs,
|
|
maxOutputBytes: 256 * 1024,
|
|
setTimeoutFn: setTimeout,
|
|
clearTimeoutFn: clearTimeout,
|
|
invalidateOnOpenTimeout: !isTargetedRecovery,
|
|
}).then(({ stdout, stderr, code }) => {
|
|
if (!isCurrentPwdOwner()) {
|
|
settle({ success: false, error: 'Session changed during cwd probe' });
|
|
return;
|
|
}
|
|
const rawPath = stdout.replace(/\r?\n$/, '');
|
|
const lsofPrefix = 'NETCATTY_LSOF_CWD=';
|
|
const path = rawPath.startsWith(lsofPrefix)
|
|
? decodeLsofFileName(rawPath.slice(lsofPrefix.length))
|
|
: rawPath;
|
|
const loginPidMatch = stderr.match(/(?:^|\n)NETCATTY_LOGIN_PID=(\d+)(?:\n|$)/);
|
|
if (loginPidMatch && !isTargetedRecovery) {
|
|
session.shellPid = loginPidMatch[1];
|
|
}
|
|
log('[getSessionPwd]', { stdout: rawPath, stderr: stderr.trim(), exitCode: code });
|
|
if (path && path.startsWith('/')) {
|
|
settle({ success: true, cwd: path });
|
|
} else {
|
|
settle({ success: false, error: 'Could not determine cwd' });
|
|
}
|
|
}, (err) => {
|
|
if (isTargetedRecovery && err?.code === "SSH_EXEC_OPEN_TIMEOUT") {
|
|
const recoveryTransport = payload?._cwdRecoveryTransport || session.connRef;
|
|
if (recoveryTransport) recoveryTransport.cwdRecoveryDisabled = true;
|
|
}
|
|
if (!isCurrentPwdOwner()) {
|
|
settle({ success: false, error: 'Session changed during cwd probe' });
|
|
return;
|
|
}
|
|
log('[getSessionPwd] exec error:', err?.message || String(err));
|
|
settle({
|
|
success: false,
|
|
error: err?.code === "SSH_EXEC_OPEN_TIMEOUT" || err?.code === "SSH_EXEC_RUN_TIMEOUT"
|
|
? "Timeout getting pwd"
|
|
: err?.message || String(err),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Resolve the directory the running `rz` writes to (its own cwd) and report
|
|
// which of `names` already exist there. Returns { dir, existing } or null.
|
|
async function probeReceiveConflicts(session, names, { signal } = {}) {
|
|
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
|
return null;
|
|
}
|
|
const script = `SELF=$$
|
|
find_login_shell() {
|
|
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
|
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
|
if ($3 != "?") { print $1; found=1; exit }
|
|
if (any == "") any=$1
|
|
}
|
|
END { if (!found && any != "") print any }'
|
|
}
|
|
find_fg_leaf() {
|
|
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
|
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
|
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
|
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
|
if(index(st[p],"+")==0) continue; d=depth(p); if(d<0) continue;
|
|
if(d>best){best=d; bp=p} } print bp }'
|
|
}
|
|
login=$(find_login_shell "$PPID")
|
|
[ -n "$login" ] || exit 0
|
|
leaf=$(find_fg_leaf "$login")
|
|
[ -n "$leaf" ] || leaf="$login"
|
|
dir=$(readlink /proc/$leaf/cwd 2>/dev/null)
|
|
[ -n "$dir" ] || exit 0
|
|
printf 'DIR\\t%s\\n' "$dir"
|
|
cd "$dir" 2>/dev/null || exit 0
|
|
for n in "$@"; do
|
|
[ -e "$n" ] || continue
|
|
m=$(stat -c %a -- "$n" 2>/dev/null || stat -f %Lp -- "$n" 2>/dev/null)
|
|
printf 'EXIST\\t%s\\t%s\\n' "$n" "$m"
|
|
done`;
|
|
const argv = names.map((n) => quoteShellArg(n)).join(" ");
|
|
const cmd = `exec sh -c ${quoteShellArg(script)} sh ${argv}`;
|
|
try {
|
|
const { stdout: out } = await executeBoundedSshCommand(session.conn, cmd, {
|
|
openingTimeoutMs: 5000,
|
|
runTimeoutMs: 5000,
|
|
maxOutputBytes: 1024 * 1024,
|
|
signal,
|
|
invalidateTransportOnAbort: false,
|
|
});
|
|
let dir = null; const existing = []; const modes = {};
|
|
for (const line of out.split("\n")) {
|
|
const [tag, val, mode] = line.split("\t");
|
|
if (tag === "DIR") dir = val;
|
|
else if (tag === "EXIST" && val) {
|
|
existing.push(val);
|
|
if (mode && /^[0-7]{3,4}$/.test(mode)) modes[val] = mode;
|
|
}
|
|
}
|
|
return dir ? { dir, existing, modes } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// rm -f the given absolute remote paths (quoted; injection-safe).
|
|
async function removeRemoteFiles(session, paths, { signal } = {}) {
|
|
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return;
|
|
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
|
const commitToken = "NETCATTY_ZMODEM_COMMIT";
|
|
const command = `exec sh -c ${quoteShellArg(
|
|
`IFS= read -r token || exit 125; ` +
|
|
`[ "$token" = ${quoteShellArg(commitToken)} ] || exit 125; ` +
|
|
`rm -f -- "$@"`,
|
|
)} sh ${argv}`;
|
|
await executeBoundedSshCommand(
|
|
session.conn,
|
|
command,
|
|
{
|
|
openingTimeoutMs: 5000,
|
|
runTimeoutMs: 5000,
|
|
maxOutputBytes: 64 * 1024,
|
|
signal,
|
|
invalidateTransportOnAbort: false,
|
|
onStream: (stream) => stream.end(`${commitToken}\n`),
|
|
},
|
|
).catch(() => {});
|
|
}
|
|
|
|
// chmod the given { path, mode } entries back to their captured permissions
|
|
// (parameterized; injection-safe). Modes are validated octal before use.
|
|
async function restoreRemoteModes(session, entries, { signal } = {}) {
|
|
if (!session || !session.conn || !Array.isArray(entries) || entries.length === 0) return;
|
|
const args = [];
|
|
for (const e of entries) {
|
|
if (!e || !e.path || !/^[0-7]{3,4}$/.test(String(e.mode))) continue;
|
|
args.push(quoteShellArg(String(e.mode)));
|
|
args.push(quoteShellArg(e.path));
|
|
}
|
|
if (args.length === 0) return;
|
|
const script = 'while [ "$#" -ge 2 ]; do chmod "$1" "$2" 2>/dev/null; shift 2; done';
|
|
const commitToken = "NETCATTY_ZMODEM_COMMIT";
|
|
const command = `exec sh -c ${quoteShellArg(
|
|
`IFS= read -r token || exit 125; ` +
|
|
`[ "$token" = ${quoteShellArg(commitToken)} ] || exit 125; ` +
|
|
script,
|
|
)} sh ${args.join(" ")}`;
|
|
await executeBoundedSshCommand(
|
|
session.conn,
|
|
command,
|
|
{
|
|
openingTimeoutMs: 5000,
|
|
runTimeoutMs: 5000,
|
|
maxOutputBytes: 64 * 1024,
|
|
signal,
|
|
invalidateTransportOnAbort: false,
|
|
onStream: (stream) => stream.end(`${commitToken}\n`),
|
|
},
|
|
).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* List directory contents on remote machine for path autocomplete.
|
|
* Uses a separate exec channel — does not touch the interactive shell.
|
|
*/
|
|
async function listSessionDir(_event, payload) {
|
|
const {
|
|
sessionId,
|
|
path: dirPath,
|
|
foldersOnly,
|
|
filterPrefix = "",
|
|
limit = 100,
|
|
} = payload || {};
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session || !session.conn) {
|
|
return { success: false, entries: [], error: 'Session not found' };
|
|
}
|
|
|
|
if (typeof dirPath !== "string" || dirPath.length === 0) {
|
|
return { success: false, entries: [], error: 'Invalid directory path' };
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const resolveOnce = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
// Emit a NUL-delimited stream from plain POSIX shell/find so we don't depend on
|
|
// Python/Perl, while still preserving whitespace and newline characters in filenames.
|
|
const safePath = dirPath.replace(/'/g, "'\\''");
|
|
const tildePathSuffix = dirPath.startsWith("~/")
|
|
? dirPath.slice(2).replace(/(["\\$`])/g, "\\$1")
|
|
: "";
|
|
const normalizedPrefix = typeof filterPrefix === "string" ? filterPrefix.toLowerCase() : "";
|
|
const safePrefix = normalizedPrefix.replace(/'/g, "'\\''");
|
|
const maxEntries = Number.isFinite(limit) ? Math.min(Math.max(1, Math.floor(limit)), 200) : 100;
|
|
const pathExpr = dirPath === "~"
|
|
? '"$HOME"'
|
|
: dirPath.startsWith("~/")
|
|
? `"$HOME/${tildePathSuffix}"`
|
|
: `'${safePath}'`;
|
|
// When dirPath is relative (not absolute and not ~/...), exec channels default
|
|
// to the user's home directory. Resolve the interactive shell's actual cwd first
|
|
// so that relative paths like "." or "src" are resolved correctly.
|
|
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
|
|
const cwdResolveCmd = needsCwdResolve
|
|
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
|
|
: '';
|
|
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
|
|
prefix="$1"
|
|
folders_only="$2"
|
|
limit="$3"
|
|
shift 3
|
|
count=0
|
|
for path do
|
|
name=\${path##*/}
|
|
lower_name=$(printf "%s" "$name" | tr "[:upper:]" "[:lower:]")
|
|
if [ -n "$prefix" ]; then
|
|
case "$lower_name" in
|
|
"$prefix"*) ;;
|
|
*) continue ;;
|
|
esac
|
|
fi
|
|
if [ "$folders_only" -eq 1 ] && [ ! -d "$path" ]; then
|
|
continue
|
|
fi
|
|
if [ -L "$path" ]; then
|
|
type="symlink"
|
|
elif [ -d "$path" ]; then
|
|
type="directory"
|
|
else
|
|
type="file"
|
|
fi
|
|
printf "%s\\0%s\\0" "$name" "$type"
|
|
count=$((count + 1))
|
|
if [ "$count" -ge "$limit" ]; then
|
|
break
|
|
fi
|
|
done
|
|
' sh '${safePrefix}' ${foldersOnly ? 1 : 0} ${maxEntries} {} + 2>/dev/null`;
|
|
|
|
void executeBoundedSshCommand(session.conn, cmd, {
|
|
openingTimeoutMs: 3000,
|
|
runTimeoutMs: 3000,
|
|
maxOutputBytes: 1024 * 1024,
|
|
}).then(({ stdout, stderr: errOut }) => {
|
|
if (settled) return;
|
|
try {
|
|
const output = Buffer.from(stdout, 'utf8');
|
|
const entries = [];
|
|
let fieldStart = 0;
|
|
let pendingName = null;
|
|
|
|
for (let i = 0; i < output.length; i++) {
|
|
if (output[i] !== 0) continue;
|
|
const field = output.toString('utf8', fieldStart, i);
|
|
fieldStart = i + 1;
|
|
if (pendingName === null) {
|
|
pendingName = field;
|
|
} else {
|
|
entries.push({ name: pendingName, type: field });
|
|
pendingName = null;
|
|
if (entries.length >= maxEntries) break;
|
|
}
|
|
}
|
|
|
|
if (pendingName !== null) {
|
|
resolveOnce({ success: false, entries: [], error: 'Invalid directory listing response' });
|
|
return;
|
|
}
|
|
|
|
resolveOnce({ success: true, entries });
|
|
} catch {
|
|
resolveOnce({
|
|
success: false,
|
|
entries: [],
|
|
error: errOut.trim() || 'Failed to parse directory listing',
|
|
});
|
|
}
|
|
}, (err) => {
|
|
resolveOnce({ success: false, entries: [], error: err?.message || String(err) });
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get server stats (CPU, Memory, Disk) from an active SSH session
|
|
* Only works for Linux servers
|
|
*/
|
|
async function getServerStats(event, payload) {
|
|
const { sessionId } = payload;
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return { success: false, error: 'Session not found or not connected' };
|
|
}
|
|
|
|
const isEtSession = session.type === "et";
|
|
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
|
|
|
|
function createBufferedExecStream(stdout = "", stderr = "") {
|
|
const stream = {
|
|
stderr: {
|
|
on(eventName, handler) {
|
|
if (eventName === "data" && stderr) {
|
|
setTimeout(() => handler(Buffer.from(stderr)), 0);
|
|
}
|
|
return this;
|
|
},
|
|
},
|
|
on(eventName, handler) {
|
|
if (eventName === "data" && stdout) {
|
|
setTimeout(() => handler(Buffer.from(stdout)), 0);
|
|
}
|
|
if (eventName === "close") {
|
|
setTimeout(() => handler(0), 0);
|
|
}
|
|
return this;
|
|
},
|
|
close() {},
|
|
destroy() {},
|
|
};
|
|
return stream;
|
|
}
|
|
|
|
function createEtStatsExecConn(etSession) {
|
|
return {
|
|
exec(command, cb) {
|
|
execOnEtSession(etSession, command, 10000, {
|
|
requireTrustedHost: true,
|
|
knownHosts: etSession.etStatsAuth?.knownHosts,
|
|
})
|
|
.then((result) => {
|
|
if (!result?.success) {
|
|
cb(new Error(result?.error || result?.stderr || "ET stats command failed"));
|
|
return;
|
|
}
|
|
cb(null, createBufferedExecStream(result.stdout || "", result.stderr || ""));
|
|
})
|
|
.catch((err) => {
|
|
cb(err instanceof Error ? err : new Error(String(err)));
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// Mosh and direct ET sessions run through external PTYs and have no
|
|
// ssh2 connection of their own. Lazily open a best-effort companion SSH
|
|
// connection (reusing credentials) so the host-info bar can use the same
|
|
// stats parser as normal SSH. The companion lives on protocol-specific
|
|
// fields — never session.conn — so other bridges do not mistake it for
|
|
// the interactive connection. ET sessions with a jump host use the
|
|
// already-prepared OpenSSH environment via execOnEtSession instead.
|
|
if (!session.conn && !session.moshStatsConn && !session.etStatsConn) {
|
|
if (session.type === 'mosh' && typeof ensureMoshStatsConnection === 'function') {
|
|
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
|
} else if (
|
|
isEtSession &&
|
|
!etUsesExecFallback &&
|
|
!session.etStatsConnFailed &&
|
|
typeof ensureEtStatsConnection === 'function'
|
|
) {
|
|
await ensureEtStatsConnection(session, sessionId, event?.sender);
|
|
}
|
|
}
|
|
|
|
const canExecEtStats =
|
|
isEtSession &&
|
|
typeof execOnEtSession === "function" &&
|
|
!!session.sshUserHost &&
|
|
!session.externalAuthArtifactsCleaned;
|
|
const sshStatsConn = session.conn || session.moshStatsConn || session.etStatsConn;
|
|
const usesEtStatsFallback = !sshStatsConn && canExecEtStats;
|
|
const conn = sshStatsConn || (usesEtStatsFallback ? createEtStatsExecConn(session) : null);
|
|
if (!conn) {
|
|
// A Mosh session can be marked "connected" (and start polling) from
|
|
// the SSH bootstrap's visible output before swapToMoshClient stores
|
|
// moshStatsAuth. During that window there is nothing to connect with
|
|
// yet — report it as `pending` (not a hard failure) so the renderer's
|
|
// give-up-after-N-failures counter doesn't permanently disable stats
|
|
// before the handshake finishes and credentials become available.
|
|
if (session.type === 'mosh' && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
|
return { success: false, pending: true, error: 'Mosh handshake in progress' };
|
|
}
|
|
return { success: false, error: 'Session not found or not connected' };
|
|
}
|
|
|
|
// macOS stats command: uses sysctl, vm_stat, ps, df, netstat
|
|
// CPU is a fast best-effort sum of process %CPU normalized by logical cores.
|
|
// cpuPerCore not available on macOS without sudo
|
|
const macosStatsCommand = [
|
|
`cores=$(sysctl -n hw.logicalcpu 2>/dev/null || echo "1")`,
|
|
`pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")`,
|
|
`memsize=$(sysctl -n hw.memsize 2>/dev/null || echo "0")`,
|
|
// CPU usage: avoid top here; on some remote macOS shells top can block long enough
|
|
// to trip the stats timeout. ps is fast and present on supported macOS versions.
|
|
`cpupct=$(ps -A -o %cpu= 2>/dev/null | awk -v c="$cores" '{s+=$1} END{if(c<=0)c=1;s=s/c;if(s<0)s=0;if(s>100)s=100;printf "%.0f",s}')`,
|
|
// Memory: single vm_stat pipe → awk extracts all page counts (strip trailing dots with gsub)
|
|
// Outputs: "memfree memcached" in MB
|
|
`vmmem=$(vm_stat 2>/dev/null | awk -v ps="$pagesize" '/^Pages free:/{gsub(/[^0-9]/,"",$NF);free=$NF+0} /^Pages speculative:/{gsub(/[^0-9]/,"",$NF);spec=$NF+0} /^Pages inactive:/{gsub(/[^0-9]/,"",$NF);inact=$NF+0} /^Pages purgeable:/{gsub(/[^0-9]/,"",$NF);purg=$NF+0} END{mfree=int((free+spec)*ps/1024/1024);mcached=int((inact+purg)*ps/1024/1024);printf "%d %d",mfree,mcached}')`,
|
|
`memtotal=$(echo "$memsize" | awk '{printf "%d",$1/1024/1024}')`,
|
|
`memfree=$(echo "$vmmem" | awk '{print $1}')`,
|
|
`memcached=$(echo "$vmmem" | awk '{print $2}')`,
|
|
// Swap
|
|
`swapraw=$(sysctl vm.swapusage 2>/dev/null)`,
|
|
`swaptotal=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="total"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);st=v*m}};printf "%.0f",st+0}')`,
|
|
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
|
|
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
|
|
// Top processes by memory%
|
|
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
|
// Disk: -P keeps a stable six-column POSIX layout on macOS.
|
|
`mounts=$(mount 2>/dev/null || true)`,
|
|
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if($1~/^\/dev\/disk/&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
|
|
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
|
|
`net=$(netstat -ibn 2>/dev/null | awk 'NR==1{for(i=1;i<=NF;i++){if($i=="Ibytes")ib=i;if($i=="Obytes")ob=i}next} ib&&ob&&$1~/^[[:alpha:]]/&&$1!~/^lo/&&$0~/<Link#/{name=$1;gsub(/[*]/,"",name);rx=$(ib)+0;tx=$(ob)+0;if(rx>0||tx>0){seen[name]=rx ":" tx}} END{for(name in seen)printf "%s:%s,",name,seen[name]}' | sed 's/,$//')`,
|
|
`hostname_value=$(hostname 2>/dev/null || uname -n 2>/dev/null || echo "")`,
|
|
`osname=$(printf "%s %s" "$(sw_vers -productName 2>/dev/null)" "$(sw_vers -productVersion 2>/dev/null)" | sed 's/[[:space:]]*$//')`,
|
|
`kernel=$(uname -r 2>/dev/null || echo "")`,
|
|
`bootsec=$(sysctl -n kern.boottime 2>/dev/null | awk -F'[ ,]+' '{for(i=1;i<=NF;i++){if($i=="sec"){print $(i+2);exit}}}')`,
|
|
`nowsec=$(date +%s 2>/dev/null || echo "0")`,
|
|
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
|
|
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
|
|
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
|
|
].join('; ');
|
|
|
|
// Prefer df's filesystem type column so FUSE mounts can be identified
|
|
// even when their source name looks like an ordinary remote path.
|
|
// Fall back to the old layout for BusyBox builds without df -T.
|
|
const linuxDiskAwk = String.raw`awk '
|
|
$0 == "__NETCATTY_DF__" { in_df = 1; next }
|
|
!in_df {
|
|
on_marker = index($0, " on ");
|
|
if (on_marker > 1) {
|
|
mount_rest = substr($0, on_marker + 4);
|
|
type_marker = index(mount_rest, " type ");
|
|
if (type_marker > 1) {
|
|
mount_point = substr(mount_rest, 1, type_marker - 1);
|
|
filesystem_type = substr(mount_rest, type_marker + 6);
|
|
sub(/[[:space:]].*$/, "", filesystem_type);
|
|
mount_filesystem_types[mount_point] = filesystem_type;
|
|
}
|
|
}
|
|
next
|
|
}
|
|
$1 == "Filesystem" { has_type = ($2 == "Type"); next }
|
|
NF > 0 {
|
|
source = $1;
|
|
if (has_type) {
|
|
filesystem_type = $2;
|
|
total_kb = $3;
|
|
used_kb = $4;
|
|
percent = $6;
|
|
mount_point = $7;
|
|
} else {
|
|
filesystem_type = "";
|
|
total_kb = $2;
|
|
used_kb = $3;
|
|
percent = $5;
|
|
mount_point = $6;
|
|
}
|
|
if ((filesystem_type == "" || filesystem_type == "-") && (mount_point in mount_filesystem_types)) {
|
|
filesystem_type = mount_filesystem_types[mount_point];
|
|
}
|
|
if (mount_point == "") next;
|
|
if (source ~ /^\/dev\/loop/ && mount_point != "/") next;
|
|
if (mount_point != "/" && mount_point ~ /^\/(etc|proc|sys|dev|snap)(\/|$)/) next;
|
|
if (mount_point != "/" && source ~ /^(overlay|overlayfs)(\/|$|:)/) next;
|
|
fstype = tolower(filesystem_type);
|
|
source_lower = tolower(source);
|
|
is_network_or_fuse = 0;
|
|
if (fstype ~ /^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|glusterfs)$/) is_network_or_fuse = 1;
|
|
if (fstype ~ /clouddrive/) is_network_or_fuse = 1;
|
|
if (fstype ~ /^(fuse|rclone|sshfs|s3fs|gcsfuse|mergerfs|unionfs|unionfs-fuse|nfs|nfs4|cifs|smb|smb3|smbfs|afs|ceph|cephfs|glusterfs)$/) is_network_or_fuse = 1;
|
|
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /clouddrive/) is_network_or_fuse = 1;
|
|
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /^(fuse|fuse\..*|rclone|rclone:.*|sshfs|s3fs|gcsfuse|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|gluster|glusterfs|ufs)$/) is_network_or_fuse = 1;
|
|
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /^\/\//) is_network_or_fuse = 1;
|
|
if ((filesystem_type == "" || filesystem_type == "-") && source_lower !~ /^(apfs|overlay|overlayfs):/ && source_lower ~ /^([a-z0-9._-]+|\[[0-9a-f:]+(%[a-z0-9._-]+)?\]):\//) is_network_or_fuse = 1;
|
|
if (is_network_or_fuse) next;
|
|
pseudo_type = (fstype != "" && fstype != "-") ? fstype : source_lower;
|
|
if (mount_point != "/" && pseudo_type ~ /^(tmpfs|shm|devtmpfs|udev|none|proc|sysfs|cgroup|cgroup2|devpts|mqueue|hugetlbfs|debugfs|tracefs|securityfs|pstore|bpf|fusectl|configfs|ramfs|rpc_pipefs|binfmt_misc|efivarfs)$/) next;
|
|
total = total_kb / 1048576;
|
|
used = used_kb / 1048576;
|
|
if (total <= 0) next;
|
|
gsub(/%/, "", percent);
|
|
if (percent !~ /^[0-9]+$/) percent = int(used * 100 / total + 0.5);
|
|
metadata = source;
|
|
if (filesystem_type != "" && filesystem_type != "-") metadata = "fs=" filesystem_type ":" source;
|
|
printf "%s:%.6f:%.6f:%s:%s,", mount_point, used, total, percent, metadata;
|
|
}
|
|
'`;
|
|
const linuxDiskTable = `{ LC_ALL=C mount 2>/dev/null; printf "%s\\n" "__NETCATTY_DF__"; LC_ALL=C df -kPT 2>/dev/null || LC_ALL=C df -kP 2>/dev/null; }`;
|
|
const linuxDiskRoot = `{ LC_ALL=C mount 2>/dev/null; printf "%s\\n" "__NETCATTY_DF__"; LC_ALL=C df -kPT / 2>/dev/null || LC_ALL=C df -kP / 2>/dev/null; }`;
|
|
|
|
// Command to get CPU (overall + per-core), Memory, and Network stats
|
|
// This command is designed to work across most Linux distributions
|
|
// Note: Using semicolons and avoiding comments for single-line execution
|
|
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
|
|
const linuxStatsCommand = [
|
|
// Get number of CPU cores
|
|
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
|
|
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
|
|
// We output raw values and calculate delta-based percentage on the backend
|
|
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
|
|
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
|
|
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
|
|
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
|
|
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
|
|
// Get top 10 processes by memory - with BusyBox fallback
|
|
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
|
|
// BusyBox fallback: top exposes %VSZ/%CPU; minimal builds without top use plain ps VSZ.
|
|
`procs=$(if procraw=$(ps -eo pid,%mem,comm --sort=-%mem 2>/dev/null); then printf "%s\n" "$procraw" | awk 'NR>1 && NR<=11 {gsub(/;/, "_", $3); printf "%s;%.1f;%s,", $1, $2, $3}' | sed 's/,$//'; elif topraw=$(top -b -n 1 2>/dev/null); then printf "%s\n" "$topraw" | awk '$1 == "PID" {for(i=1;i<=NF;i++){if($i=="%VSZ") mem_col=i; else if($i=="COMMAND") cmd_col=i} next} $1 ~ /^[0-9]+$/ && mem_col && cmd_col {pct=$(mem_col); gsub(/%/, "", pct); cmd=$(cmd_col); gsub(/;/, "_", cmd); print pct ";" $1 ";" cmd}' | sort -t ';' -k1,1rn | head -10 | awk -F';' '{printf "%s;%.1f;%s,", $2, $1, $3}' | sed 's/,$//'; else ps ww 2>/dev/null | awk -v total=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) '$1 ~ /^[0-9]+$/ {v=$3; unit=substr(v,length(v),1); mult=1; if(unit=="m"||unit=="M")mult=1024; else if(unit=="g"||unit=="G")mult=1048576; sub(/[mMgG]$/, "", v); pct=total>0?v*mult*100/total:0; cmd=$5; gsub(/;/, "_", cmd); print pct, $1, cmd}' | sort -rn | head -10 | awk '{printf "%s;%.1f;%s,", $2, $1, $3}' | sed 's/,$//'; fi)`,
|
|
// Get network interface stats from /proc/net/dev (interface:rx_bytes:tx_bytes), excluding lo and virtual interfaces
|
|
`net=$(cat /proc/net/dev 2>/dev/null | awk 'NR>2 {gsub(/^[ \\t]+/, ""); split($0, a, ":"); iface=a[1]; if(iface != "lo" && iface !~ /^veth/ && iface !~ /^docker/ && iface !~ /^br-/) {split(a[2], b); printf "%s:%s:%s,", iface, b[1], b[9]}}' | sed 's/,$//' || echo "")`,
|
|
`hostname_value=$(hostname 2>/dev/null || uname -n 2>/dev/null || echo "")`,
|
|
`osname=$(awk -F= '/^PRETTY_NAME=/{gsub(/"/,"",$2);print $2;exit}' /etc/os-release 2>/dev/null || uname -s 2>/dev/null || echo "")`,
|
|
`kernel=$(uname -r 2>/dev/null || echo "")`,
|
|
`uptime=$(awk '{printf "%.0f",$1}' /proc/uptime 2>/dev/null || echo "")`,
|
|
`loadavg=$(awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null || echo "")`,
|
|
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
|
|
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
|
].join('; ');
|
|
|
|
// Client-side run bound shared by the stats and disk probes; the remote
|
|
// watchdog uses the same value so an abandoned probe self-kills instead
|
|
// of lingering on the target host (#3187).
|
|
const statsRunTimeoutMs = 10000;
|
|
|
|
// Get mounted disk info. GNU and BusyBox support df -T; the awk parser
|
|
// also accepts the legacy POSIX -kP layout as a fallback. PVE/LXC guests
|
|
// often expose ZFS datasets and host bind mounts without a /dev/* source,
|
|
// so keep non-pseudo filesystems. Skip FUSE/cloud/NFS/CIFS network mounts:
|
|
// their quotas are not local capacity. Keep a loop-backed rootfs while
|
|
// dropping snap loops, derive missing percentages, and fall back to "/".
|
|
const linuxDiskStatsCommand = withPosixRemoteWatchdog([
|
|
`disks=$( { ${linuxDiskTable}; } | ${linuxDiskAwk} | sed 's/,$//' )`,
|
|
`[ -n "$disks" ] || disks=$( { ${linuxDiskRoot}; } | ${linuxDiskAwk} | sed 's/,$//' )`,
|
|
`echo "DISKS:$disks"`,
|
|
].join('; '), statsRunTimeoutMs);
|
|
|
|
// Dropbear rejects command requests larger than 9000 bytes by closing
|
|
// the entire SSH transport, including an already-open interactive shell.
|
|
// Keep Linux's large disk parser in its own request so future stats
|
|
// additions cannot repeat issue #2924.
|
|
const dropbearMaxCommandBytes = 9000;
|
|
const latencyMarker = "NC_LATENCY_MARK";
|
|
|
|
// Windows PowerShell stats command - uses same output format as Linux/macOS
|
|
// so the existing parser works without modification.
|
|
// Note: CPU is read directly from Win32_Processor.LoadPercentage (no delta needed).
|
|
const windowsStatsCommand = [
|
|
`$ErrorActionPreference = 'SilentlyContinue'`,
|
|
`$cs = Get-CimInstance Win32_ComputerSystem`,
|
|
`$os = Get-CimInstance Win32_OperatingSystem`,
|
|
`$cpu = Get-CimInstance Win32_Processor`,
|
|
`$cores = [int](($cpu | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)`,
|
|
`$cpuLoad = [int](($cpu | Measure-Object -Property LoadPercentage -Average).Average)`,
|
|
`$memTotalMB = [math]::Round($os.TotalVisibleMemorySize / 1024)`,
|
|
`$memFreeMB = [math]::Round($os.FreePhysicalMemory / 1024)`,
|
|
`$memCachedMB = 0`,
|
|
`$memBuffersMB = 0`,
|
|
`try { $cache = Get-CimInstance Win32_PerfFormattedData_Memory_CacheMemory; if ($cache -and $cache.StandbyCacheNormalPriorityBytes) { $memCachedMB = [math]::Round($cache.StandbyCacheNormalPriorityBytes / 1MB) } } catch { }`,
|
|
`$swapTotalMB = [math]::Round([long]$os.TotalVirtualMemorySize / 1024)`,
|
|
`$swapFreeMB = [math]::Round([long]$os.FreeVirtualMemory / 1024)`,
|
|
`$procs = Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10`,
|
|
`$procStr = ($procs | ForEach-Object { "$($_.Id);$([math]::Round($_.WorkingSet64 / ([long]$os.TotalVisibleMemorySize * 1024) * 100, 1));$($_.ProcessName -replace ';','_')" }) -join ','`,
|
|
`$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3"`,
|
|
`$diskStr = ($disks | ForEach-Object { $used = [long]$_.Size - [long]$_.FreeSpace; $pct = if ([long]$_.Size -gt 0) { [math]::Round($used / [long]$_.Size * 100) } else { 0 }; "$($_.DeviceID.TrimEnd(':')):$([math]::Round($used / 1GB, 2)):$([math]::Round([long]$_.Size / 1GB, 2)):$pct:fs=NTFS:$($_.VolumeName)" }) -join ','`,
|
|
// Network: use raw performance counter data (cumulative bytes)
|
|
// so the backend's delta-based speed calculation works correctly.
|
|
`$nets = Get-CimInstance Win32_PerfRawData_Tcpip_NetworkInterface | Where-Object { $_.Name -notmatch 'Loopback|Teredo|isatap|6TO4|_Total' }`,
|
|
`$netStr = ($nets | ForEach-Object { "$($_.Name -replace ':','_'):$([long]$_.BytesReceivedPersec):$([long]$_.BytesSentPersec)" }) -join ','`,
|
|
`$upTime = (Get-Date) - $os.LastBootUpTime`,
|
|
`$upSeconds = [math]::Round($upTime.TotalSeconds)`,
|
|
`$loadAvg = ''`,
|
|
`Write-Output "${latencyMarker}|"`,
|
|
`Write-Output "CPU:$cpuLoad|CORES:$cores|MEMINFO:$memTotalMB $memFreeMB $memBuffersMB $memCachedMB $swapTotalMB $swapFreeMB|PROCS:$procStr|DISKS:$diskStr|NET:$netStr|HOST:$($env:COMPUTERNAME)|OS:Windows $($os.Version) ($($os.Caption.Replace('|','_')))|KERNEL:$($os.Version)|UPTIME:$upSeconds|LOAD:$loadAvg"`,
|
|
].join('; ');
|
|
|
|
const statsCommand = withPosixRemoteWatchdog(
|
|
`printf "${latencyMarker}|"; ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`,
|
|
statsRunTimeoutMs,
|
|
);
|
|
// Encode script as UTF-16LE for PowerShell -EncodedCommand.
|
|
// PowerShell expects UTF-16LE bytes Base64-encoded.
|
|
// windowsStatsCommand is ASCII-dominant so we can safely zero-extend each UTF-8 byte
|
|
// to a UTF-16LE code unit (ASCII chars are identical in both encodings).
|
|
const utf8Buf = Buffer.from(windowsStatsCommand, 'utf8');
|
|
const utf16leBuf = Buffer.alloc(utf8Buf.length * 2);
|
|
for (let i = 0; i < utf8Buf.length; i++) {
|
|
utf16leBuf[i * 2] = utf8Buf[i];
|
|
utf16leBuf[i * 2 + 1] = 0;
|
|
}
|
|
const encodedScript = utf16leBuf.toString('base64');
|
|
const windowsPowershellCommand = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedScript}`;
|
|
const statsExecOptions = {
|
|
openingTimeoutMs: statsRunTimeoutMs,
|
|
runTimeoutMs: statsRunTimeoutMs,
|
|
maxOutputBytes: 1024 * 1024,
|
|
setTimeoutFn: setTimeout,
|
|
clearTimeoutFn: clearTimeout,
|
|
};
|
|
const executeStatsCommand = (command, options = statsExecOptions) => {
|
|
const commandBytes = Buffer.byteLength(command, 'utf8');
|
|
if (commandBytes > dropbearMaxCommandBytes) {
|
|
const error = new Error(`Server stats command exceeds Dropbear limit (${commandBytes} bytes)`);
|
|
error.code = "SSH_EXEC_COMMAND_LIMIT";
|
|
return Promise.reject(error);
|
|
}
|
|
return executeBoundedSshCommand(conn, command, options);
|
|
};
|
|
const tcpLatencyTarget = getTcpLatencyTarget(session);
|
|
const tcpLatencyPromise = tcpLatencyTarget && typeof measureTcpConnectLatency === 'function'
|
|
? Promise.resolve(measureTcpConnectLatency(tcpLatencyTarget)).catch(() => null)
|
|
: Promise.resolve(null);
|
|
const formatStatsError = (error) => (
|
|
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
|
|
? "Timeout getting server stats"
|
|
: error?.message || String(error)
|
|
);
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return false;
|
|
settled = true;
|
|
resolve(result);
|
|
return true;
|
|
};
|
|
void (async () => {
|
|
try {
|
|
let combinedStdout = '';
|
|
let triedWindows = false;
|
|
let posixFailed = false;
|
|
|
|
try {
|
|
const posixResult = await executeStatsCommand(statsCommand);
|
|
if (settled) return;
|
|
combinedStdout = String(posixResult.stdout || '').trim();
|
|
console.log('[ServerStats] POSIX result:', JSON.stringify({
|
|
code: posixResult.code,
|
|
stdoutLen: combinedStdout.length,
|
|
stdoutPreview: combinedStdout.substring(0, 200),
|
|
stderrPreview: String(posixResult.stderr || '').substring(0, 200),
|
|
}));
|
|
} catch (posixError) {
|
|
console.log('[ServerStats] POSIX threw:', posixError?.message || String(posixError));
|
|
posixFailed = true;
|
|
combinedStdout = '';
|
|
}
|
|
|
|
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
|
|
const hasKnownPrefix = primaryOutput.startsWith('CPURAW:') || primaryOutput.startsWith('CPU:');
|
|
const isUnsupported = posixFailed || primaryOutput.startsWith('UNSUPPORTED_OS:') || !hasKnownPrefix;
|
|
console.log('[ServerStats] POSIX analysis:', JSON.stringify({ posixFailed, hasKnownPrefix, isUnsupported, primaryPreview: primaryOutput.substring(0, 100) }));
|
|
|
|
// If POSIX failed or returned unrecognized output, try Windows PowerShell
|
|
if (isUnsupported) {
|
|
console.log('[ServerStats] Triggering Windows PowerShell fallback. commandLen=', windowsPowershellCommand.length, 'cmdPreview=', windowsPowershellCommand.substring(0, 120));
|
|
try {
|
|
const winResult = await executeStatsCommand(windowsPowershellCommand);
|
|
if (settled) return;
|
|
const winStdout = String(winResult.stdout || '').trim();
|
|
const winStderr = String(winResult.stderr || '');
|
|
console.log('[ServerStats] PowerShell result:', JSON.stringify({
|
|
code: winResult.code,
|
|
stdoutLen: winStdout.length,
|
|
stdoutFull: winStdout.substring(0, 800),
|
|
stderrFull: winStderr.substring(0, 800),
|
|
}));
|
|
const winPrimary = winStdout.replace(new RegExp(`^${latencyMarker}\\|?\\s*`), '');
|
|
if (winPrimary && winPrimary.startsWith('CPU:')) {
|
|
combinedStdout = winStdout;
|
|
triedWindows = true;
|
|
console.log('[ServerStats] PowerShell output accepted as valid');
|
|
} else {
|
|
console.log('[ServerStats] PowerShell output REJECTED (no CPU: prefix). winPrimary=', winPrimary.substring(0, 200));
|
|
}
|
|
} catch (winError) {
|
|
console.log('[ServerStats] PowerShell threw:', winError?.message || String(winError));
|
|
if (primaryOutput.startsWith('UNSUPPORTED_OS:')) {
|
|
settle({ success: false, error: `Server stats not supported on this OS (${primaryOutput.substring(15)})` });
|
|
} else {
|
|
settle({ success: false, error: `PowerShell exec failed: ${winError?.message || winError}` });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// After Windows fallback, re-check for meaningful data
|
|
const finalOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?\\s*`), '');
|
|
console.log('[ServerStats] Final check:', JSON.stringify({ hasData: !!finalOutput, finalPreview: finalOutput.substring(0, 150), triedWindows }));
|
|
if (!finalOutput || finalOutput.startsWith('UNSUPPORTED_OS:')) {
|
|
if (finalOutput.startsWith('UNSUPPORTED_OS:')) {
|
|
settle({ success: false, error: `Server stats not supported on this OS (${finalOutput.substring(15)})` });
|
|
} else {
|
|
settle({ success: false, error: 'Unable to retrieve server stats (POSIX returned unknown output, PowerShell produced no valid CPU data)' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Linux has separate disk stats command
|
|
if (finalOutput.startsWith('CPURAW:') && !triedWindows) {
|
|
try {
|
|
const diskResult = await executeStatsCommand(linuxDiskStatsCommand);
|
|
combinedStdout = [combinedStdout, String(diskResult.stdout || '').trim()]
|
|
.filter(Boolean)
|
|
.join('|');
|
|
} catch (diskError) {
|
|
// Non-fatal: continue without disk data
|
|
}
|
|
}
|
|
const measuredLatency = await tcpLatencyPromise;
|
|
if (settled) return;
|
|
const latencyMs = Number.isFinite(measuredLatency) ? measuredLatency : null;
|
|
|
|
// Parse the output
|
|
const output = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?\\s*`), '');
|
|
|
|
// Unsupported OS — stop polling this session
|
|
if (output.startsWith('UNSUPPORTED_OS:')) {
|
|
settle({ success: false, error: `Server stats not supported on this OS (${output.substring(15)})` });
|
|
return;
|
|
}
|
|
|
|
const parts = output.split('|');
|
|
|
|
let cpuDirect = null; // macOS: direct CPU percentage from top
|
|
let cpuRawTotal = null;
|
|
let cpuRawIdle = null;
|
|
let cpuPerCoreRaw = []; // Array of { total, idle }
|
|
let cpuCores = null;
|
|
let memTotal = null;
|
|
let memFree = null;
|
|
let memBuffers = null;
|
|
let memCached = null;
|
|
let memUsed = null;
|
|
let swapTotal = null;
|
|
let swapUsed = null;
|
|
let topProcesses = []; // Array of { pid, memPercent, command }
|
|
let disks = []; // Array of { mountPoint, used, total, percent }
|
|
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
|
let hostname = "";
|
|
let osName = "";
|
|
let kernelRelease = "";
|
|
let uptimeSeconds = null;
|
|
let loadAverage = [];
|
|
|
|
for (const part of parts) {
|
|
if (part.startsWith('CPU:')) {
|
|
// macOS: command reports normalized CPU% directly (no delta needed)
|
|
const val = parseFloat(part.substring(4).trim());
|
|
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
|
|
} else if (part.startsWith('CPURAW:')) {
|
|
const rawParts = part.substring(7).trim().split(/\s+/);
|
|
if (rawParts.length >= 2) {
|
|
cpuRawTotal = parseInt(rawParts[0], 10);
|
|
cpuRawIdle = parseInt(rawParts[1], 10);
|
|
}
|
|
} else if (part.startsWith('CORES:')) {
|
|
const coreStr = part.substring(6).trim();
|
|
const val = parseInt(coreStr, 10);
|
|
if (!isNaN(val) && val > 0) cpuCores = val;
|
|
} else if (part.startsWith('PERCORERAW:')) {
|
|
const coreStr = part.substring(11).trim();
|
|
if (coreStr && coreStr !== '') {
|
|
cpuPerCoreRaw = coreStr.split(',').map(v => {
|
|
const coreParts = v.trim().split(':');
|
|
if (coreParts.length >= 2) {
|
|
const total = parseInt(coreParts[0], 10);
|
|
const idle = parseInt(coreParts[1], 10);
|
|
if (!isNaN(total) && !isNaN(idle)) {
|
|
return { total, idle };
|
|
}
|
|
}
|
|
return null;
|
|
}).filter(v => v !== null);
|
|
}
|
|
} else if (part.startsWith('MEMINFO:')) {
|
|
const memParts = part.substring(8).trim().split(/\s+/);
|
|
if (memParts.length >= 4) {
|
|
const total = parseInt(memParts[0], 10);
|
|
const free = parseInt(memParts[1], 10);
|
|
const buffers = parseInt(memParts[2], 10);
|
|
const cached = parseInt(memParts[3], 10);
|
|
if (!isNaN(total)) memTotal = total;
|
|
if (!isNaN(free)) memFree = free;
|
|
if (!isNaN(buffers)) memBuffers = buffers;
|
|
if (!isNaN(cached)) memCached = cached;
|
|
// Calculate used memory (excluding buffers/cache)
|
|
if (memTotal !== null && memFree !== null && memBuffers !== null && memCached !== null) {
|
|
memUsed = memTotal - memFree - memBuffers - memCached;
|
|
if (memUsed < 0) memUsed = 0;
|
|
}
|
|
// Parse swap info (fields 5 and 6)
|
|
if (memParts.length >= 6) {
|
|
const st = parseInt(memParts[4], 10);
|
|
const sf = parseInt(memParts[5], 10);
|
|
if (!isNaN(st)) swapTotal = st;
|
|
if (!isNaN(sf)) {
|
|
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
|
|
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
|
|
}
|
|
}
|
|
}
|
|
} else if (part.startsWith('PROCS:')) {
|
|
const procsStr = part.substring(6).trim();
|
|
if (procsStr && procsStr !== '') {
|
|
const procEntries = procsStr.split(',');
|
|
for (const entry of procEntries) {
|
|
const procParts = entry.split(';'); // Using ; as delimiter
|
|
if (procParts.length >= 3) {
|
|
const pid = procParts[0];
|
|
const memPercent = parseFloat(procParts[1]);
|
|
const command = procParts.slice(2).join(';'); // Command might contain semicolons
|
|
if (!isNaN(memPercent)) {
|
|
topProcesses.push({ pid, memPercent, command });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (part.startsWith('DISKS:')) {
|
|
const disksStr = part.substring(6).trim();
|
|
if (disksStr && disksStr !== '') {
|
|
const diskEntries = disksStr.split(',');
|
|
for (const entry of diskEntries) {
|
|
const diskParts = entry.split(':');
|
|
if (diskParts.length >= 4) {
|
|
const mountPoint = diskParts[0];
|
|
const used = parseFloat(diskParts[1]);
|
|
const total = parseFloat(diskParts[2]);
|
|
let percent = parseInt(diskParts[3], 10);
|
|
if (Number.isNaN(percent) && Number.isFinite(used) && Number.isFinite(total) && total > 0) {
|
|
percent = Math.round((used / total) * 100);
|
|
}
|
|
if (!Number.isNaN(used) && !Number.isNaN(total) && !Number.isNaN(percent) && total > 0) {
|
|
const diskMetadata = diskParts.slice(4).join(':').trim();
|
|
let capacityKey = diskMetadata;
|
|
let filesystemType;
|
|
if (diskMetadata.startsWith('fs=')) {
|
|
const separator = diskMetadata.indexOf(':');
|
|
if (separator > 3) {
|
|
filesystemType = diskMetadata.slice(3, separator).trim();
|
|
capacityKey = diskMetadata.slice(separator + 1).trim();
|
|
}
|
|
}
|
|
disks.push({
|
|
mountPoint,
|
|
used,
|
|
total,
|
|
percent,
|
|
...(capacityKey ? { capacityKey } : {}),
|
|
...(filesystemType ? { filesystemType } : {}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (part.startsWith('NET:')) {
|
|
const netStr = part.substring(4).trim();
|
|
if (netStr && netStr !== '') {
|
|
const netEntries = netStr.split(',');
|
|
for (const entry of netEntries) {
|
|
const netParts = entry.split(':');
|
|
if (netParts.length >= 3) {
|
|
const name = netParts[0];
|
|
const rxBytes = parseInt(netParts[1], 10);
|
|
const txBytes = parseInt(netParts[2], 10);
|
|
if (!isNaN(rxBytes) && !isNaN(txBytes)) {
|
|
networkInterfaces.push({ name, rxBytes, txBytes });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (part.startsWith('HOST:')) {
|
|
hostname = part.substring(5).trim();
|
|
} else if (part.startsWith('OS:')) {
|
|
osName = part.substring(3).trim();
|
|
} else if (part.startsWith('KERNEL:')) {
|
|
kernelRelease = part.substring(7).trim();
|
|
} else if (part.startsWith('UPTIME:')) {
|
|
const uptimeText = part.substring(7).trim();
|
|
if (uptimeText !== '') {
|
|
const uptime = Number(uptimeText);
|
|
if (Number.isFinite(uptime) && uptime >= 0) uptimeSeconds = uptime;
|
|
}
|
|
} else if (part.startsWith('LOAD:')) {
|
|
const loadText = part.substring(5).trim();
|
|
if (loadText !== '') {
|
|
loadAverage = loadText.split(/\s+/)
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value >= 0)
|
|
.slice(0, 3);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate network speed based on previous reading
|
|
const now = Date.now();
|
|
const prevNet = session.prevNetStats || { interfaces: [], timestamp: 0 };
|
|
const timeDelta = (now - prevNet.timestamp) / 1000; // seconds
|
|
|
|
let netRxSpeed = 0; // bytes per second
|
|
let netTxSpeed = 0; // bytes per second
|
|
const netInterfaces = [];
|
|
|
|
if (timeDelta > 0 && prevNet.interfaces.length > 0) {
|
|
for (const iface of networkInterfaces) {
|
|
const prevIface = prevNet.interfaces.find(p => p.name === iface.name);
|
|
if (prevIface) {
|
|
const rxDelta = iface.rxBytes - prevIface.rxBytes;
|
|
const txDelta = iface.txBytes - prevIface.txBytes;
|
|
// Only count positive deltas (handles counter reset)
|
|
const rxSpeed = rxDelta > 0 ? Math.round(rxDelta / timeDelta) : 0;
|
|
const txSpeed = txDelta > 0 ? Math.round(txDelta / timeDelta) : 0;
|
|
netRxSpeed += rxSpeed;
|
|
netTxSpeed += txSpeed;
|
|
netInterfaces.push({
|
|
name: iface.name,
|
|
rxBytes: iface.rxBytes,
|
|
txBytes: iface.txBytes,
|
|
rxSpeed,
|
|
txSpeed,
|
|
});
|
|
} else {
|
|
netInterfaces.push({
|
|
name: iface.name,
|
|
rxBytes: iface.rxBytes,
|
|
txBytes: iface.txBytes,
|
|
rxSpeed: 0,
|
|
txSpeed: 0,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// First reading - no speed data yet
|
|
for (const iface of networkInterfaces) {
|
|
netInterfaces.push({
|
|
name: iface.name,
|
|
rxBytes: iface.rxBytes,
|
|
txBytes: iface.txBytes,
|
|
rxSpeed: 0,
|
|
txSpeed: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Store current reading for next calculation
|
|
session.prevNetStats = {
|
|
interfaces: networkInterfaces,
|
|
timestamp: now,
|
|
};
|
|
|
|
// Calculate CPU usage based on delta from previous reading
|
|
const prevCpu = session.prevCpuStats || { total: 0, idle: 0, perCore: [], timestamp: 0 };
|
|
let cpu = null;
|
|
let cpuPerCore = [];
|
|
|
|
if (cpuRawTotal !== null && cpuRawIdle !== null && prevCpu.total > 0) {
|
|
const totalDelta = cpuRawTotal - prevCpu.total;
|
|
const idleDelta = cpuRawIdle - prevCpu.idle;
|
|
if (totalDelta > 0) {
|
|
// CPU% = 100 - (idleDelta / totalDelta * 100)
|
|
cpu = Math.round(100 - (idleDelta / totalDelta * 100));
|
|
// Clamp to valid range
|
|
if (cpu < 0) cpu = 0;
|
|
if (cpu > 100) cpu = 100;
|
|
}
|
|
}
|
|
|
|
// macOS: use direct percentage from top (no delta needed)
|
|
if (cpu === null && cpuDirect !== null) {
|
|
cpu = cpuDirect;
|
|
}
|
|
|
|
// Calculate per-core CPU usage from deltas
|
|
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
|
|
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
|
|
const prevCore = prevCpu.perCore[index];
|
|
if (prevCore) {
|
|
const totalDelta = core.total - prevCore.total;
|
|
const idleDelta = core.idle - prevCore.idle;
|
|
if (totalDelta > 0) {
|
|
let usage = Math.round(100 - (idleDelta / totalDelta * 100));
|
|
if (usage < 0) usage = 0;
|
|
if (usage > 100) usage = 100;
|
|
return usage;
|
|
}
|
|
}
|
|
return 0;
|
|
});
|
|
} else if (cpuPerCoreRaw.length > 0) {
|
|
// First reading - no delta data yet, return zeros
|
|
cpuPerCore = cpuPerCoreRaw.map(() => 0);
|
|
}
|
|
|
|
// Store current CPU reading for next calculation
|
|
session.prevCpuStats = {
|
|
total: cpuRawTotal || 0,
|
|
idle: cpuRawIdle || 0,
|
|
perCore: cpuPerCoreRaw,
|
|
timestamp: now,
|
|
};
|
|
|
|
// For backward compatibility, extract root disk info
|
|
const rootDisk = disks.find(d => d.mountPoint === '/');
|
|
const diskPercent = rootDisk ? rootDisk.percent : null;
|
|
const diskUsed = rootDisk ? rootDisk.used : null;
|
|
const diskTotal = rootDisk ? rootDisk.total : null;
|
|
|
|
// If no meaningful data was parsed, treat as failure to stop futile polling
|
|
if (cpu === null && memTotal === null && cpuCores === null) {
|
|
settle({ success: false, error: 'Unable to parse server stats (unsupported OS or shell)' });
|
|
return;
|
|
}
|
|
|
|
settle({
|
|
success: true,
|
|
stats: {
|
|
cpu, // CPU usage percentage (0-100)
|
|
cpuCores, // Number of CPU cores
|
|
cpuPerCore, // Per-core CPU usage array
|
|
memTotal, // Total memory in MB
|
|
memUsed, // Used memory in MB (excluding buffers/cache)
|
|
memFree, // Free memory in MB
|
|
memBuffers, // Buffers in MB
|
|
memCached, // Cached in MB
|
|
swapTotal, // Swap total in MB
|
|
swapUsed, // Swap used in MB
|
|
topProcesses, // Top 10 processes by memory
|
|
diskPercent, // Disk usage percentage for root partition (backward compat)
|
|
diskUsed, // Disk used in GB for root partition (backward compat)
|
|
diskTotal, // Total disk in GB for root partition (backward compat)
|
|
disks, // Array of all mounted disks
|
|
netRxSpeed, // Total network receive speed (bytes/sec)
|
|
netTxSpeed, // Total network transmit speed (bytes/sec)
|
|
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
|
|
netInterfaces, // Per-interface network stats
|
|
hostname,
|
|
osName,
|
|
kernelRelease,
|
|
uptimeSeconds,
|
|
loadAverage,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
settle({ success: false, error: formatStatsError(error) });
|
|
}
|
|
})();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set terminal encoding for an active SSH session
|
|
*/
|
|
async function setSessionEncoding(_event, { sessionId, encoding }) {
|
|
const session = sessions?.get(sessionId);
|
|
if (!session || !session.stream) {
|
|
return { ok: false, encoding: encoding || "utf-8" };
|
|
}
|
|
const enc = normalizeTerminalEncoding(encoding);
|
|
if (!iconv.encodingExists(enc)) {
|
|
return { ok: false, encoding: enc };
|
|
}
|
|
sessionEncodings.set(sessionId, enc);
|
|
// Mirror onto the session record so the terminal input path
|
|
// (terminalBridge.writeToSession) encodes keystrokes with the same
|
|
// charset the output decoder now uses — keeping input/output symmetric
|
|
// on non-UTF-8 devices (issue #1216).
|
|
session.encoding = enc;
|
|
// Reset stateful decoders so new data uses the updated encoding
|
|
resetSessionDecoders(sessionId);
|
|
return { ok: true, encoding: enc };
|
|
}
|
|
|
|
return {
|
|
getSessionRemoteInfo,
|
|
getSessionDistroInfo,
|
|
readRemoteHistory,
|
|
getSessionPwd,
|
|
probeReceiveConflicts,
|
|
removeRemoteFiles,
|
|
restoreRemoteModes,
|
|
listSessionDir,
|
|
getServerStats,
|
|
setSessionEncoding,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createSessionOpsApi, decodeLsofFileName };
|