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