Files
NetMesh/electron/bridges/sshBridge/moshStatsConnection.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

508 lines
23 KiB
JavaScript

/* eslint-disable no-undef */
/**
* Companion SSH connection for Mosh sessions.
*
* A Mosh session runs over UDP via a local `mosh-client` PTY and therefore
* has no ssh2 `Client` (`session.conn`) — the very thing `getServerStats`
* needs to run its periodic `/proc`-based stats command on an exec channel.
* Without it the terminal's host-info bar (CPU / memory / disk / network)
* stays empty for Mosh, while SSH sessions show it (issue #1198).
*
* This module lazily opens a *second*, stats-only ssh2 connection to the
* same host using the same credentials the Mosh handshake already used, and
* stores it as `session.conn` so the existing `getServerStats` code path
* works unchanged. It is intentionally best-effort and non-interactive:
*
* - It never prompts the user for a password or key passphrase. The Mosh
* handshake (driven by the system `ssh` in the user's PTY) is where the
* real, interactive auth happens; this companion only reuses credentials
* Netcatty already holds (stored password, parseable private key,
* unencrypted / stored-passphrase identity files, ssh-agent).
* - If it cannot authenticate or connect, it fails silently and records
* the failure so it does not hammer the host on every stats poll. Mosh
* keeps working; only the stats bar stays empty (graceful degradation).
*
* Security — host-key handling:
* - The companion connects ONLY to a host whose live key is already
* "trusted" in Netcatty's known-hosts store. A host verifier classifies
* the key during the transport handshake and REJECTS the connection for an
* unknown / changed key — for every auth method, not just password. This is
* done silently and never prompts: the user vets and trusts a host key
* through the real interactive session (#1191), and this background stats
* poll only ever rides on a host that vetting already approved. An
* untrusted host just leaves the stats bar empty (graceful degradation).
* - Rejecting outright (rather than merely withholding the password) is
* deliberate. Even though public-key / ssh-agent auth discloses no reusable
* secret and its signature is session-bound, a *background, user-invisible*
* connection that authenticated against an unverified host would still run
* the stats command there — letting a MITM / DNS-spoofed host feed bogus
* host-info to the user and enumerate the agent's public keys. That breaks
* the same host-key guarantee the interactive session enforces, so the
* companion refuses unvetted hosts regardless of auth method.
* - A gated authHandler additionally withholds the plaintext password until
* the verifier has confirmed trust, as defense in depth.
*/
function createMoshStatsConnectionApi(ctx) {
// Read off ctx (not via the `with` scope) so an absent dependency reads as
// `undefined` instead of throwing a ReferenceError under `with`. Optional:
// when not wired in (e.g. older callers / some unit tests) the verifier
// simply skips the system-known_hosts fallback.
const isHostKeyTrustedBySystem = ctx.isHostKeyTrustedBySystem;
with (ctx) {
// Resolve a usable, non-interactive private key (+ passphrase) for the
// companion connection. Returns null when the key is missing, encrypted
// without a usable stored passphrase, or otherwise unparseable — the
// caller then falls back to password / agent auth or gives up.
function resolveNonInteractiveKey(privateKey, passphrase) {
if (typeof privateKey !== "string" || privateKey.trim().length === 0) {
return null;
}
try {
const parsed = sshUtils.parseKey(privateKey, passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, passphrase: passphrase || undefined };
}
} catch {
// parseKey throws on malformed input — treat as unusable.
}
return null;
}
// Read identity files from disk without prompting. Only unencrypted keys
// (or keys whose stored passphrase parses them) are returned.
async function resolveNonInteractiveIdentityFiles(identityFilePaths, passphrase) {
if (!Array.isArray(identityFilePaths) || identityFilePaths.length === 0) {
return [];
}
const keys = [];
for (const rawPath of identityFilePaths) {
if (typeof rawPath !== "string" || rawPath.trim().length === 0) continue;
const resolvedPath = expandIdentityFilePath(rawPath);
let content;
try {
content = await readFileNoFollow(resolvedPath);
} catch {
continue;
}
if (!content) continue;
const key = resolveNonInteractiveKey(content, passphrase);
if (key) keys.push(key);
}
return keys;
}
// An ssh2 hostVerifier that ACCEPTS the transport only when the live host
// key is already trusted — by Netcatty's in-app known-hosts store OR by the
// user's *system* OpenSSH known_hosts — and REJECTS it for an unknown /
// changed key. It never prompts — an untrusted host fails the background
// companion silently (stats stay empty) instead of popping a modal the user
// can't meaningfully answer for a stats poll.
//
// Why also consult the system known_hosts: a Mosh session is bootstrapped
// by the system `ssh`, which records (and vets, via its own prompt) the
// host key in `~/.ssh/known_hosts`. Netcatty's vault snapshot is NOT updated
// by that handshake, so a host the user trusted purely through system ssh
// would otherwise be misread as "unknown" and the companion permanently
// disabled — leaving the stats bar empty even though the system already
// trusts the exact key. We match the LIVE key's SHA-256 fingerprint against
// those files, so this only ever grants trust for the precise key the user's
// own OpenSSH already trusts; it never accepts an arbitrary or mismatched
// key. Unknown / changed keys stay rejected.
//
// Rejecting (not merely gating password auth) is required: a background,
// user-invisible connection that completed key/agent auth against an
// unverified host would still run the stats command there, letting a MITM /
// DNS-spoofed host feed bogus host-info and enumerate the agent's public
// keys. `trust.trusted` additionally gates the password method in the
// authHandler (defense in depth); `trust.rejected` lets the caller treat an
// untrusted host as a permanent failure so it stops reconnecting every poll.
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, verifyHostKeys = true, trust, label }) {
return (rawKey, callback) => {
if (verifyHostKeys === false) {
trust.trusted = true;
callback(true);
return;
}
try {
const keyInfo = hostKeyVerifier.describeHostKey(rawKey);
const decision = hostKeyVerifier.classifyHostKey({
knownHosts: Array.isArray(knownHosts) ? knownHosts : [],
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
trust.trusted = decision.status === "trusted";
// Fall back to the system OpenSSH known_hosts (Mosh's real trust
// source) only when Netcatty's snapshot does not already vouch for
// the key. Matching is by the live key's fingerprint, so this can
// only confirm — never override a mismatch into acceptance.
if (!trust.trusted && isHostKeyTrustedBySystem) {
trust.trusted = isHostKeyTrustedBySystem({
hostname,
port,
fingerprint: keyInfo.fingerprint,
}) === true;
}
} catch (err) {
log(`[${label}] stats companion host-key check failed:`, err?.message || String(err));
trust.trusted = false;
}
if (!trust.trusted) trust.rejected = true;
callback(trust.trusted);
};
}
// A function-form ssh2 authHandler that offers, in order: none, agent (if
// available), publickey (if a key was resolved), and — only when the host
// key is trusted — password and keyboard-interactive. Agent/password
// methods use names from connectOpts; identity files use explicit key
// objects so every discovered key can be attempted. This is what actually
// withholds the password from an untrusted host while still letting
// key/agent auth succeed.
function createGatedAuthHandler({ hasAgent, keys, hasPassword, trust, username }) {
const methods = ["none"];
if (hasAgent) methods.push("agent");
for (const key of keys) {
methods.push({
type: "publickey",
username,
key: key.privateKey,
passphrase: key.passphrase,
});
}
let index = 0;
let trustedMethodsAppended = false;
return (_methodsLeft, _partialSuccess, callback) => {
// Append the password methods lazily, the first time we run out of the
// always-allowed ones, so the trust flag (set by the verifier during
// the transport handshake) is up to date.
if (index >= methods.length && !trustedMethodsAppended) {
trustedMethodsAppended = true;
if (hasPassword && trust.trusted) {
methods.push("password", "keyboard-interactive");
}
}
if (index >= methods.length) {
callback(false);
return;
}
callback(methods[index++]);
};
}
async function buildStatsConnectOpts(auth, label = "Mosh") {
const connectOpts = {
host: auth.hostname,
port: auth.port || 22,
username: auth.username || "root",
// Stats are a background nicety — keep the timeout short so a slow or
// firewalled host fails fast instead of holding a poll for 30s+.
readyTimeout: 10000,
keepaliveInterval: 0,
// Honor the host's algorithm settings so the companion negotiates the
// same KEX / cipher / host-key set as the interactive session would.
algorithms: buildAlgorithms(auth.legacyAlgorithms, {
skipEcdsaHostKey: auth.skipEcdsaHostKey,
algorithmOverrides: auth.algorithmOverrides,
}),
};
const hasCertificate =
typeof auth.certificate === "string" && auth.certificate.trim().length > 0;
const allowLocalKeyFallbackWithAgent = auth.authMethod === "auto";
const inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? null
: resolveNonInteractiveKey(auth.privateKey, auth.passphrase);
const keys = inlineKey
? [inlineKey]
: auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? []
: await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase);
const key = keys[0] || null;
let agent = null;
if (hasCertificate && key) {
try {
agent = new NetcattyAgent({
mode: "certificate",
webContents: auth.webContents,
meta: {
label: auth.keyId || auth.username || "",
certificate: auth.certificate,
privateKey: key.privateKey,
passphrase: key.passphrase,
},
});
connectOpts.agent = agent;
} catch {
// Certificate could not be parsed non-interactively — fall through
// to plain key / password auth below.
agent = null;
}
}
if (!agent && key) {
connectOpts.privateKey = key.privateKey;
if (key.passphrase) connectOpts.passphrase = key.passphrase;
}
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
connectOpts.agent = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
}
if (typeof auth.password === "string" && auth.password.length > 0) {
connectOpts.password = auth.password;
// Many SSH servers (PAM-backed) only offer password auth through
// keyboard-interactive, not the plain "password" method. The Mosh
// handshake's system ssh handles that via its PTY responder, so the
// companion must too — otherwise stats stay empty on those hosts
// despite a saved password. The handler (attached at connect time)
// auto-fills non-interactively and never shows a prompt.
connectOpts.tryKeyboard = true;
}
// ssh-agent fallback whenever a socket is available and no *explicit*
// key / certificate-agent was resolved. The Mosh handshake runs the
// system `ssh` with the inherited environment, so it authenticates via
// the local ssh-agent by default — independent of agentForwarding
// (which only controls *remote* forwarding). This is offered alongside
// any saved password (ssh2 tries agent before password), so a
// public-key host that also happens to have a stored password still
// authenticates via the agent instead of failing on password-only.
if (auth.useSshAgent !== false && !connectOpts.agent && !agent && !inlineKey) {
const agentSocket = getSshAgentSocket(auth.identityAgent);
if (agentSocket) {
connectOpts.agent = agentSocket;
}
}
const hasAnyAuth = Boolean(
connectOpts.agent || connectOpts.privateKey || connectOpts.password,
);
// Always install a host verifier that refuses an untrusted host, for
// EVERY auth method — a background, user-invisible companion must never
// authenticate to or run commands against a host Netcatty has not vetted
// (it could feed bogus host-info or enumerate agent keys), even though
// key/agent auth discloses no reusable secret.
const trust = { trusted: false, rejected: false };
connectOpts.hostVerifier = createTrustEnforcingHostVerifier({
hostname: connectOpts.host,
port: connectOpts.port,
knownHosts: auth.knownHosts,
verifyHostKeys: auth.verifyHostKeys,
trust,
label,
});
// When a plaintext password is in play, also gate it behind the trust
// flag in the authHandler (defense in depth): key/agent methods are
// offered first, and the password / keyboard-interactive methods only
// once the verifier has confirmed the host key is trusted.
const authKeys = agent ? [] : keys;
if (connectOpts.password || authKeys.length > 1) {
connectOpts.authHandler = createGatedAuthHandler({
hasAgent: Boolean(connectOpts.agent),
keys: authKeys,
hasPassword: Boolean(connectOpts.password),
trust,
username: connectOpts.username,
});
}
return { connectOpts, hasAnyAuth, trust };
}
/**
* Ensure a Mosh session has a usable stats companion connection.
*
* Returns the ssh2 Client on success (also stored on
* `session.moshStatsConn`), or null when one could not be established.
*
* The companion is stored ONLY on `session.moshStatsConn`, deliberately
* NOT on `session.conn`: other bridges treat `session.conn` as the
* session's primary interactive SSH connection (getSessionPwd assumes its
* exec channel is a sibling of the interactive shell; SFTP / MCP exec run
* over it). A Mosh session's interactive shell lives on the UDP
* mosh-client, not on this background stats connection, so exposing it as
* `session.conn` would make those paths return bogus results or run over
* the wrong connection. Only getServerStats reads `session.moshStatsConn`.
*
* Safe to call repeatedly: concurrent calls share a single in-flight
* attempt, and a permanent failure is cached so later polls don't
* reconnect on every tick.
*
* @param {object} session - the shared session record
* @param {string} sessionId - the session's key in the shared sessions map
* @param {Electron.WebContents} [webContents] - sender of the stats IPC,
* used only for certificate-agent construction.
*/
function ensureStatsConnection(session, sessionId, webContents, opts) {
if (!session) return Promise.resolve(null);
// A previously established companion is reused.
if (session[opts.connProp]) return Promise.resolve(session[opts.connProp]);
// A prior attempt permanently failed — don't keep retrying every poll.
if (session[opts.failedProp]) return Promise.resolve(null);
// Reuse an in-flight attempt so two near-simultaneous polls don't open
// two connections.
if (session[opts.promiseProp]) return session[opts.promiseProp];
const promise = establishStatsConnection(session, sessionId, webContents, opts).finally(() => {
session[opts.promiseProp] = null;
});
session[opts.promiseProp] = promise;
return promise;
}
function ensureMoshStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "Mosh",
authProp: "moshStatsAuth",
connProp: "moshStatsConn",
failedProp: "moshStatsConnFailed",
promiseProp: "moshStatsConnPromise",
});
}
function ensureEtStatsConnection(session, sessionId, webContents) {
return ensureStatsConnection(session, sessionId, webContents, {
label: "ET",
authProp: "etStatsAuth",
connProp: "etStatsConn",
failedProp: "etStatsConnFailed",
promiseProp: "etStatsConnPromise",
});
}
// True once the session has gone away — either explicitly closed or
// dropped from the shared map (e.g. the mosh-client PTY exited while we
// were still connecting the companion).
function sessionGone(session, sessionId) {
return session.closed || sessions.get(sessionId) !== session;
}
async function establishStatsConnection(session, sessionId, webContents, opts) {
const auth = session[opts.authProp];
if (!auth || !auth.hostname) {
// moshStatsAuth is only assigned once the handshake completes and the
// session swaps to mosh-client. The renderer can mark a session
// "connected" (and start polling) from the SSH bootstrap's visible
// PTY output *before* that swap, so a missing auth here is transient —
// do NOT permanently disable stats, or the companion would never be
// attempted after the handshake finishes.
return null;
}
const { connectOpts, hasAnyAuth, trust } = await buildStatsConnectOpts({
...auth,
webContents,
}, opts.label);
if (!hasAnyAuth) {
// Nothing we can authenticate with non-interactively (e.g. the user
// typed a password into the Mosh handshake PTY that we never stored).
session[opts.failedProp] = true;
return null;
}
// The session may have been closed while we were reading identity files.
if (sessionGone(session, sessionId)) {
return null;
}
return new Promise((resolve) => {
const conn = new SSHClient();
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
// Non-interactive keyboard-interactive auth: auto-fill the saved
// password for a single password prompt and never show a modal. On a
// 2FA / multi-prompt / OTP challenge we finish empty so ssh2 moves on
// to the next method (or fails) instead of hanging on a prompt the
// user can't answer for a background connection.
if (connectOpts.tryKeyboard && connectOpts.password) {
// Only auto-fill once. If the password was wrong, ssh2 may re-issue
// the challenge; finishing empty on the retry lets auth fail cleanly
// instead of looping on the same wrong password.
let autoFilledOnce = false;
conn.on("keyboard-interactive", (_name, _instr, _lang, prompts, finishKbd) => {
if (!autoFilledOnce && isAutoFillablePasswordChallenge(prompts, connectOpts.password)) {
autoFilledOnce = true;
finishKbd([connectOpts.password]);
} else {
finishKbd([]);
}
});
}
// `permanent` distinguishes futile retries (auth rejected, or a throw
// building the connection) from transient ones (network blip,
// timeout). Only the former disables stats for the session's
// lifetime; transient errors just skip this poll and let the next one
// retry.
const fail = (err, permanent) => {
try { conn.end(); } catch { /* ignore */ }
if (permanent) session[opts.failedProp] = true;
finish(null);
};
conn.once("ready", () => {
// The session may have been closed while we were connecting.
if (sessionGone(session, sessionId)) {
try { conn.end(); } catch { /* ignore */ }
finish(null);
return;
}
// Stored only on the protocol-specific companion property
// (opts.connProp, e.g. moshStatsConn / etStatsConn) — never on
// session.conn (see ensureMoshStatsConnection docstring for why).
session[opts.connProp] = conn;
finish(conn);
});
conn.on("error", (err) => {
log(`[${opts.label}] stats companion connection error:`, err?.message || String(err));
// If this fired after we already adopted the connection, drop the
// stale handle so the next poll can rebuild a fresh one.
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// Auth rejection won't change with the same stored credentials, and a
// host-key rejection (untrusted host) won't either until the user
// vets the host via a real session — treat both as permanent so we
// stop reconnecting on every poll. Everything else may be transient.
fail(err, err?.level === "client-authentication" || trust.rejected);
});
conn.on("close", () => {
if (session[opts.connProp] === conn) session[opts.connProp] = null;
// If the socket closed mid-handshake without ever emitting "ready"
// or "error", settle the attempt here so the awaiting getServerStats
// call (and the in-flight promise on opts.promiseProp) don't hang
// forever. This
// is treated as transient — the next poll may retry.
finish(null);
});
try {
conn.connect(connectOpts);
} catch (err) {
log(`[${opts.label}] stats companion connect threw:`, err?.message || String(err));
// A synchronous throw from connect() (e.g. malformed options) won't
// succeed on retry either.
fail(err, true);
}
});
}
return { ensureMoshStatsConnection, ensureEtStatsConnection };
}
}
module.exports = { createMoshStatsConnectionApi };