Files
SenMeshNet/electron/bridges/sshBridge/sessionOps.cjs
zhaolei 2f9f8da48e
Some checks failed
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
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

2147 lines
102 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-undef */
const { Client: SSHClient } = require("ssh2");
const { buildAlgorithms } = require("../sshAlgorithms.cjs");
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' };
}
// ---------- NETWORK DEVICE STATS via independent SSH + PTY shell ----------
// H3C / Huawei VRP / Cisco IOS / etc. only allow ONE SSH channel per
// transport — an exec channel on the interactive conn drops the whole
// connection. Open a SEPARATE SSH transport (independent Client, PTY
// shell, paging disabled), run display commands, disconnect. This
// mirrors the inspection executor pattern — no shared state with the
// user's live session.
const _v = session.remoteSshVersion || '';
const isNetworkDeviceSsh = /^H3C[-_\s]/i.test(_v)
|| /^Comware-/i.test(_v)
|| /^HUAWEI[-_]/i.test(_v)
|| /^VRP-/i.test(_v)
|| _v === '-'
|| /^Cisco[-_]|^CiscoIOS_/i.test(_v)
|| /^RGOS_SSH/i.test(_v)
|| /^JUNOS/i.test(_v)
|| /H3C/i.test(_v) // 通用 H3C 检测
|| /Comware/i.test(_v); // 通用 Comware 检测
if (isNetworkDeviceSsh && session._statsAuth?.hostname) {
try {
return await collectNetworkDeviceStats(session);
} catch (err) {
log("network device stats failed", { sessionId, error: err?.message || String(err) });
return { success: false, error: err?.message || String(err) };
}
}
async function collectNetworkDeviceStats(sess) {
const auth = sess._statsAuth;
const fs = require('fs');
const logPath = require('path').join(require('os').tmpdir(), 'netmesh-stats-debug.log');
const dlog = (m) => { try { fs.appendFileSync(logPath, `\n[${new Date().toISOString()}] ${m}`); } catch {} };
dlog(`=== collectNetworkDeviceStats START hostname=${auth.hostname}`);
const connOpts = {
host: auth.hostname,
port: auth.port || 22,
username: auth.username || 'root',
readyTimeout: 15000,
keepaliveInterval: 0,
algorithms: buildAlgorithms(auth.legacyAlgorithms, {
skipEcdsaHostKey: auth.skipEcdsaHostKey,
}),
};
if (typeof auth.password === 'string' && auth.password.length > 0) {
connOpts.password = auth.password;
connOpts.tryKeyboard = true;
}
if (typeof auth.privateKey === 'string' && auth.privateKey.length > 0) {
connOpts.privateKey = auth.privateKey;
if (auth.passphrase) connOpts.passphrase = auth.passphrase;
}
return new Promise((resolve) => {
const client = new SSHClient();
let settled = false;
let stream = null;
let timer = null;
const finish = (result) => {
if (settled) return;
settled = true;
dlog(`finish: ${JSON.stringify(result).slice(0, 200)}`);
try { timer && clearTimeout(timer); } catch {}
try { stream && stream.end(); } catch {}
try { client.end(); } catch {}
resolve(result);
};
client.once('banner', (msg) => dlog(`banner: ${msg.slice(0, 100)}`));
client.once('handshake', () => dlog('handshake OK'));
client.once('ready', () => {
dlog('ready event → opening shell');
client.shell({ term: 'vt100', cols: 120, rows: 40 }, (err, s) => {
if (err) {
dlog(`shell open FAILED: ${err.message}`);
finish({ success: false, error: 'shell open failed: ' + err.message });
return;
}
dlog('shell opened OK');
stream = s;
collectStatsOverShell(stream, finish, dlog);
});
});
client.once('error', (err) => {
dlog(`error event: ${err?.message || String(err)} level=${err?.level}`);
finish({ success: false, error: err?.message || String(err) });
});
client.once('close', () => {
dlog(`close event settled=${settled}`);
if (!settled) finish({ success: false, error: 'SSH connection closed' });
});
try { client.connect(connOpts); dlog('connect() called'); }
catch (err) { dlog(`connect() threw: ${err.message}`); finish({ success: false, error: err?.message || String(err) }); }
});
}
function collectStatsOverShell(stream, finish, dlog) {
let phaseBuffer = '';
let phase = 'init'; // init → disable-paging → cpu → mem → intf → quit → done
let output = { cpu: null, memTotal: null, memUsed: null, memPercent: null, ports: [] };
let phaseCompleted = false;
const PROMPT_RE = /^<[\w\-./]+>$|^[\w\-./]+[>#]$/;
const commands = [
{ phase: 'disable-paging', cmd: 'screen-length 0 temporary', noParse: true },
{ phase: 'cpu', cmd: 'display cpu-usage' },
{ phase: 'mem', cmd: 'display memory' },
{ phase: 'intf', cmd: 'display interface brief' },
];
let cmdIdx = 0;
let globalTimeout = null;
let initFired = false;
let quitSent = false;
// --- Parsers ---
function parseCpuUsage(text) {
// H3C Comware 5/7: "7% in last 5 seconds" 或 "3% in last 1 minute"
const m = text.match(/(\d+)\s*%\s*in\s+last\s+5\s+seconds/i)
|| text.match(/(\d+)\s*%\s*in\s+last\s+1\s+minute/i)
|| text.match(/5\s*Seconds\s*[::]\s*(\d+)\s*%/i)
|| text.match(/\(5-\d+\s*second\)\s*[::]\s*(\d+)\s*%/i)
|| text.match(/CPU\s+utilization[^.]*?(\d{1,3})\s*%/i);
if (m) return parseInt(m[1], 10);
return null;
}
function parseMemory(text) {
// H3C Comware: "Used Rate: 44%" 或 "Memory Using Percentage Is: 21%"
const pctM = text.match(/Used\s*Rate\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i)
|| text.match(/Memory\s+Using\s+Percentage\s+Is\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
const percent = pctM ? parseFloat(pctM[1]) : null;
// bytes 格式: "System Total Memory(bytes): 65546160"
const totalBytesM = text.match(/System\s+Total\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
const usedBytesM = text.match(/Total\s+Used\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
let total = null, used = null;
if (totalBytesM && usedBytesM) {
total = parseInt(totalBytesM[1], 10);
used = parseInt(usedBytesM[1], 10);
}
// MB 格式: "Memory Size: xxx MB"
const totalM = text.match(/Memory\s*(?:Size|Total)\s*[::]?\s*(\d+)\s*MB/i);
const usedM = text.match(/Memory\s*Used\s*[::]?\s*(\d+)\s*MB/i);
if (totalM && usedM) {
total = parseInt(totalM[1], 10);
used = parseInt(usedM[1], 10);
}
return {
total,
used,
percent,
};
}
function parseInterfaceBrief(text) {
const ports = [];
for (const line of text.split(/\r?\n/)) {
// H3C 格式: "GE1/0/1 UP 100M(a)" 或 "GigabitEthernet1/0/1 UP UP"
const geMatch = line.match(/^(GE\d+\/\d+\/\d+)\s+(UP|DOWN|up|down)/i);
if (geMatch) {
const phy = geMatch[2].toLowerCase();
ports.push({ name: geMatch[1], phy: phy.includes('up') ? 'up' : 'down' });
continue;
}
const m = line.match(/^([A-Za-z][\w\-]+(?:\d+[\/.])*\d+)\s+(UP|DOWN|up|down)/i);
if (m && !/^(Name|Interface|Port)\b/i.test(m[1])) {
const phy = (m[2] || '').toLowerCase();
ports.push({ name: m[1], phy: phy.includes('up') ? 'up' : 'down' });
}
}
return ports;
}
function sendNextCommand() {
clearTimeout(globalTimeout);
if (cmdIdx >= commands.length) {
dlog('ALL COMMANDS DONE → quit');
// 标记 quit 阶段:此时交换机关连接是预期行为,不要当错误处理
phase = 'quit';
quitSent = true;
try { stream.write('quit\n'); } catch {}
// 交换机 quit 后会立刻关 VTY(close → error),等 1.5s 让它走完再 resolve
globalTimeout = setTimeout(() => {
dlog('quit timeout → resolve');
resolveOutput();
}, 1500);
return;
}
const c = commands[cmdIdx];
phase = c.phase;
phaseBuffer = '';
phaseCompleted = false;
dlog(`>>> WRITE: ${c.cmd}`);
try { stream.write(c.cmd + '\n'); } catch (err) { dlog(`write error: ${err.message}`); }
// 每个 phase 10s timeout
globalTimeout = setTimeout(() => {
dlog(`phase ${phase} TIMEOUT, skip`);
cmdIdx++;
setTimeout(sendNextCommand, 200);
}, 10000);
}
function resolveOutput() {
const upCount = output.ports.filter(p => p.phy === 'up').length;
const downCount = output.ports.length - upCount;
// 网络设备 display memory 通常只返回百分比(如 21%),没有 total/used MB
// 构造假值让 ratioPercent(memUsed, memTotal) 能正确算出百分比
let memTotal = output.memTotal;
let memUsed = output.memUsed;
if (memTotal == null && memUsed == null && output.memPercent != null) {
memTotal = 100;
memUsed = output.memPercent;
}
const full = {
success: true,
stats: {
hostname: null, osName: null, kernelRelease: null, uptimeSeconds: null,
loadAverage: [],
cpu: output.cpu, cpuCores: null, cpuPerCore: [],
memTotal, memUsed,
memFree: memTotal != null && memUsed != null ? Math.max(0, memTotal - memUsed) : null,
memBuffers: null, memCached: null, swapTotal: null, swapUsed: null,
topProcesses: [], diskPercent: null, diskUsed: null, diskTotal: null, disks: [],
netRxSpeed: 0, netTxSpeed: 0, latencyMs: null, netInterfaces: [],
lastUpdated: Date.now(),
networkDevicePorts: output.ports,
networkDevicePortUp: upCount,
networkDevicePortDown: downCount,
},
};
dlog(`RESOLVE ✓ cpu=${output.cpu} memPercent=${output.memPercent} memTotal=${memTotal} memUsed=${memUsed} ports=${output.ports.length} up=${upCount} down=${downCount}`);
dlog(`FULL JSON has networkDevicePorts? ${Array.isArray(full.stats.networkDevicePorts)} len=${full.stats.networkDevicePorts?.length}`);
finish(full);
}
stream.on('data', (data) => {
const text = data.toString('utf-8');
phaseBuffer += text;
const lastLine = (phaseBuffer.split(/\r?\n/).pop() || '').trimEnd();
// 处理分页提示符
if (phase === 'intf' && (phaseBuffer.includes('-- More --') || phaseBuffer.includes('---- More ----'))) {
dlog('检测到分页,发送空格继续');
stream.write(' ');
// 移除分页提示符避免干扰
phaseBuffer = phaseBuffer.replace(/--\s*More\s*--/gi, '').replace(/----\s*More\s*----/gi, '');
}
if (!phaseCompleted && PROMPT_RE.test(lastLine)) {
phaseCompleted = true;
dlog(`PROMPT [${phase}]: ${lastLine}`);
if (phase === 'init') {
// 第一次收到 shell prompt → 触发第一条命令
// 关键:不要 cmdIdx++!直接 sendNextCommand 让它从 cmdIdx=0 开始
initFired = true;
setTimeout(sendNextCommand, 300);
return;
}
if (phase === 'cpu') {
output.cpu = parseCpuUsage(phaseBuffer);
dlog(` → cpu=${output.cpu}`);
} else if (phase === 'mem') {
const m = parseMemory(phaseBuffer);
output.memTotal = m.total; output.memUsed = m.used; output.memPercent = m.percent;
dlog(` → memTotal=${m.total} memUsed=${m.used} memPercent=${m.percent}`);
} else if (phase === 'intf') {
output.ports = parseInterfaceBrief(phaseBuffer);
dlog(` → ports=${output.ports.length}`);
}
cmdIdx++;
setTimeout(sendNextCommand, 200);
}
});
stream.on('close', () => {
dlog(`stream close event quitSent=${quitSent}`);
clearTimeout(globalTimeout);
// quit 后交换机正常关 VTY → 直接 resolve,让后面的 client error 看到 settled=true
if (quitSent) {
dlog('→ quit-expected close, resolve now');
resolveOutput();
}
});
// Init fallback: 5s 内没收到 shell prompt,也手动触发第一条
setTimeout(() => {
if (!initFired) {
dlog('INIT FALLBACK fire (no shell prompt yet)');
initFired = true;
sendNextCommand();
}
}, 5000);
// 15s 全局超时(兜底)
globalTimeout = setTimeout(() => finish({ success: false, error: 'network device stats timeout' }), 20000);
}
function resolveNetworkDeviceStats(output, finish) {
const upCount = output.ports.filter(p => p.phy === 'up').length;
const downCount = output.ports.length - upCount;
// Map to the standard ServerStats shape the frontend expects.
// Fields we cannot derive from H3C/Huawei display commands (processes,
// disks, per-core CPU, load average, sparkline samples) are left null.
finish({
success: true,
stats: {
hostname: null,
osName: null,
kernelRelease: null,
uptimeSeconds: null,
loadAverage: [],
cpu: output.cpu,
cpuCores: null,
cpuPerCore: [],
memTotal: output.memTotal,
memUsed: output.memUsed,
memFree: output.memTotal && output.memUsed ? Math.max(0, output.memTotal - output.memUsed) : null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: Date.now(),
// Extended fields for network devices (the POSIX path never sets
// these — they are purely for panel rendering):
networkDevicePorts: output.ports,
networkDevicePortUp: upCount,
networkDevicePortDown: downCount,
},
});
}
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 };