Files
NetMesh/electron/bridges/sshBridge/sessionOps.cjs

1801 lines
87 KiB
JavaScript
Raw Normal View History

/* 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 };