[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
507
electron/bridges/sshBridge/moshStatsConnection.cjs
Normal file
507
electron/bridges/sshBridge/moshStatsConnection.cjs
Normal file
@@ -0,0 +1,507 @@
|
||||
/* 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 };
|
||||
Reference in New Issue
Block a user