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
2660 lines
119 KiB
JavaScript
2660 lines
119 KiB
JavaScript
/* eslint-disable no-undef */
|
|
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
|
const {
|
|
setBufferedOutputBytes,
|
|
shouldAcceptSessionOutput,
|
|
shouldProcessSessionOutput,
|
|
} = require("../terminalFlowAck.cjs");
|
|
const {
|
|
filterTerminalInterruptOutput,
|
|
takePendingInterruptOutputMeta,
|
|
} = require("../terminalInterruptOutputGate.cjs");
|
|
const {
|
|
logTerminalInterruptDrainDropSample,
|
|
logTerminalOutputDropSample,
|
|
} = require("../terminalInterruptDiagnostics.cjs");
|
|
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
|
|
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
|
|
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
|
|
const { listInteractiveShellPids: listInteractiveShellPidsShared } = require("../sshInteractiveShells.cjs");
|
|
const {
|
|
shouldConfirmReusedShellLiveness,
|
|
resolveReusedShellLivenessMs,
|
|
waitForReusedShellLiveness,
|
|
} = require("../sshIdleParkPolicy.cjs");
|
|
const {
|
|
annotateMacLocalNetworkErrorMessage,
|
|
resolveFirstTcpEndpoint,
|
|
} = require("../macLocalNetworkAccess.cjs");
|
|
|
|
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
|
|
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
|
const MAX_SSH_CONNECTION_TIMEOUT_MS = 3600000;
|
|
const COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS = 30000;
|
|
|
|
/**
|
|
* Fan out netcatty:exit to the primary contents plus any attach-home owner
|
|
* (AI observe popup rebind) so neither side is left stale.
|
|
*/
|
|
function safeSendSessionExit(ctx, primaryContents, sessionId, payload) {
|
|
const { safeSend, electronModule, sessions } = ctx;
|
|
const seen = new Set();
|
|
const sendTo = (contents) => {
|
|
if (!contents || typeof contents.id !== "number" || seen.has(contents.id)) return;
|
|
seen.add(contents.id);
|
|
try {
|
|
safeSend(contents, "netcatty:exit", payload);
|
|
} catch {
|
|
// ignore destroyed renderers
|
|
}
|
|
};
|
|
sendTo(primaryContents);
|
|
try {
|
|
const live = sessions?.get?.(sessionId);
|
|
if (typeof live?.webContentsId === "number") {
|
|
sendTo(electronModule?.webContents?.fromId?.(live.webContentsId));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
const homeId = getAttachHomeWebContentsId(sessionId);
|
|
if (typeof homeId === "number") {
|
|
sendTo(electronModule?.webContents?.fromId?.(homeId));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function normalizeSshConnectionTimeoutMs(value, fallback) {
|
|
return Number.isFinite(value) && value >= 1000 && value <= MAX_SSH_CONNECTION_TIMEOUT_MS
|
|
? Math.round(value)
|
|
: fallback;
|
|
}
|
|
|
|
function resolveSshConnectionTimeouts(options = {}) {
|
|
return {
|
|
tcpConnectTimeoutMs: normalizeSshConnectionTimeoutMs(
|
|
options.sshTcpConnectTimeoutMs,
|
|
SSH_TCP_CONNECT_TIMEOUT_MS,
|
|
),
|
|
authReadyTimeoutMs: normalizeSshConnectionTimeoutMs(
|
|
options.sshAuthReadyTimeoutMs,
|
|
SSH_AUTH_READY_TIMEOUT_MS,
|
|
),
|
|
};
|
|
}
|
|
|
|
function isSshAuthFailure(err) {
|
|
const message = err?.message?.toLowerCase() || "";
|
|
return err?.level === "client-authentication" ||
|
|
message.includes("all configured authentication methods failed") ||
|
|
message.includes("authentication failed") ||
|
|
message.includes("too many authentication failures") ||
|
|
/permission denied\s*\(/.test(message) ||
|
|
message.includes("no authentication methods available");
|
|
}
|
|
|
|
function userVisibleSshErrorMessage(err, options = {}) {
|
|
const firstHop = resolveFirstTcpEndpoint(options);
|
|
return annotateMacLocalNetworkErrorMessage(err?.message || String(err || ""), {
|
|
hostname: options.hostname || options.host,
|
|
firstHopHostname: firstHop.skipProbe ? "" : firstHop.hostname,
|
|
firstHopResolvedAddress: firstHop.skipProbe
|
|
? ""
|
|
: (options._macLocalNetworkResolvedFirstHop || options.firstHopResolvedAddress || ""),
|
|
skipProbe: firstHop.skipProbe === true,
|
|
});
|
|
}
|
|
|
|
function hasSelectedAgentIdentity(options) {
|
|
const hasInlinePublicKey = Array.isArray(options?.agentPublicKeys)
|
|
&& options.agentPublicKeys.some((key) => typeof key === "string" && key.trim().length > 0);
|
|
const hasReferencedIdentity = Array.isArray(options?.identityFilePaths)
|
|
&& options.identityFilePaths.some((filePath) => typeof filePath === "string" && filePath.trim().length > 0);
|
|
return hasInlinePublicKey || hasReferencedIdentity;
|
|
}
|
|
|
|
function shouldOfferAgentForLogin(options, connectOpts) {
|
|
const selectedMethod = options?.authMethod;
|
|
const hasRestrictedSelectedAgent = selectedMethod === "key"
|
|
&& options?.useSshAgent === true
|
|
&& options?.identitiesOnly === true
|
|
&& hasSelectedAgentIdentity(options);
|
|
const isStrictMethod = selectedMethod === "password"
|
|
|| selectedMethod === "key"
|
|
|| selectedMethod === "certificate";
|
|
return (!isStrictMethod || hasRestrictedSelectedAgent)
|
|
&& options?.useSshAgent !== false
|
|
&& Boolean(connectOpts?.agent);
|
|
}
|
|
|
|
function shouldPrepareSystemAgentForLogin(options) {
|
|
if (options?.authMethod === "password" || options?.authMethod === "certificate") return false;
|
|
if (options?.authMethod !== "key") return true;
|
|
return options?.useSshAgent === true
|
|
&& options?.identitiesOnly === true
|
|
&& hasSelectedAgentIdentity(options);
|
|
}
|
|
|
|
function resolveUnlockedEncryptedKeysForAuth(options, strictAgentSelection) {
|
|
const selectedMethod = options?.authMethod;
|
|
const hasStrictMethod = selectedMethod === "password"
|
|
|| selectedMethod === "key"
|
|
|| selectedMethod === "certificate";
|
|
return strictAgentSelection || hasStrictMethod ? [] : (options?._unlockedEncryptedKeys || []);
|
|
}
|
|
|
|
function shouldPromoteCachedAuthMethod(authMethod, cachedMethod) {
|
|
if (!cachedMethod) return false;
|
|
if (authMethod === "auto") {
|
|
return cachedMethod === "agent" || cachedMethod.startsWith("publickey");
|
|
}
|
|
if (authMethod === "password" || authMethod === "key" || authMethod === "certificate") {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function applyAgentForwarding(
|
|
options,
|
|
connectOpts,
|
|
resolveForwardingAgentSocket,
|
|
) {
|
|
if (!options?.agentForwarding) return connectOpts;
|
|
const alreadyResolved = Object.prototype.hasOwnProperty.call(
|
|
options,
|
|
"_resolvedForwardingAgentSocket",
|
|
);
|
|
const forwardingAgent = alreadyResolved
|
|
? options._resolvedForwardingAgentSocket
|
|
: await resolveForwardingAgentSocket(options.identityAgent, options);
|
|
if (forwardingAgent) {
|
|
connectOpts.agent = forwardingAgent;
|
|
connectOpts.agentForward = true;
|
|
}
|
|
return connectOpts;
|
|
}
|
|
|
|
async function prepareAgentForwardingOptions(options, resolveForwardingAgentSocket) {
|
|
if (!options?.agentForwarding) return options;
|
|
if (Object.prototype.hasOwnProperty.call(options, "_resolvedForwardingAgentSocket")) {
|
|
return {
|
|
...options,
|
|
forwardingAgentSocket: options._resolvedForwardingAgentSocket || "",
|
|
};
|
|
}
|
|
const forwardingAgent = await resolveForwardingAgentSocket(options.identityAgent, options);
|
|
return {
|
|
...options,
|
|
_resolvedForwardingAgentSocket: forwardingAgent || null,
|
|
forwardingAgentSocket: forwardingAgent || "",
|
|
};
|
|
}
|
|
|
|
function createStartSessionApi(ctx) {
|
|
with (ctx) {
|
|
const listInteractiveShellPids = (conn) => listInteractiveShellPidsShared(conn, {
|
|
quoteShellArg,
|
|
});
|
|
|
|
const listInteractiveShellPidsResilient = async (conn, opts = {}) => {
|
|
const attempts = Math.max(1, Number(opts.attempts) || 1);
|
|
const backoffMs = Math.max(1, Number(opts.backoffMs) || 150);
|
|
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
|
|
let last = { available: false, pids: [] };
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
const delayMs = attempt === 0
|
|
? initialDelayMs
|
|
: backoffMs * attempt;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
last = await listInteractiveShellPids(conn);
|
|
if (last.available || !last.rateLimited) return last;
|
|
}
|
|
return last;
|
|
};
|
|
|
|
const ensureConcurrentJoinShellIdentity = async (connRef, options) => {
|
|
if (!connRef || options.skipShellPidDiscovery) return true;
|
|
|
|
const current = [...sessions.entries()].filter(([, candidate]) => (
|
|
candidate?.connRef === connRef
|
|
&& candidate?.stream
|
|
));
|
|
if (current.length === 0) return false;
|
|
if (current.every(([, candidate]) => candidate.shellPid)) return true;
|
|
if (current.length !== 1) return false;
|
|
|
|
const [[sessionId, candidate]] = current;
|
|
const discovery = await listInteractiveShellPids(connRef.conn);
|
|
const live = sessions.get(sessionId);
|
|
if (
|
|
discovery.available
|
|
&& discovery.pids.length === 1
|
|
&& live === candidate
|
|
&& live.connRef === connRef
|
|
&& live.stream === candidate.stream
|
|
&& !live.shellPid
|
|
) {
|
|
live.shellPid = String(discovery.pids[0]);
|
|
}
|
|
return Boolean(
|
|
live === candidate
|
|
&& live.connRef === connRef
|
|
&& live.stream === candidate.stream
|
|
&& live.shellPid
|
|
);
|
|
};
|
|
|
|
const waitForNewInteractiveShellPid = async (conn, previousPids, opts = {}) => {
|
|
const previous = new Set(previousPids);
|
|
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
|
|
const backoffMs = Math.max(1, Number(opts.backoffMs) || 50);
|
|
if (initialDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
|
|
}
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const discovery = await listInteractiveShellPids(conn);
|
|
if (!discovery.available) {
|
|
// Bastion rate limits can reject the post-open discovery exec just
|
|
// after a retried shell open. Back off and try again instead of
|
|
// permanently leaving the copied session without a shellPid.
|
|
if (discovery.rateLimited && attempt < 4) {
|
|
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
|
|
continue;
|
|
}
|
|
return null;
|
|
}
|
|
const newPids = discovery.pids.filter((pid) => !previous.has(pid));
|
|
if (newPids.length === 1) return newPids[0];
|
|
if (newPids.length > 1) return null;
|
|
if (attempt < 4) {
|
|
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Wire up a freshly-opened shell channel (PTY stream) for a session:
|
|
* output buffering, ZMODEM handling, encoding, exit/close reporting and
|
|
* teardown. Shared by both the fresh-connection path and the connection
|
|
* reuse path (issue #1204) so duplicated tabs behave identically to the
|
|
* original tab once their channel is open.
|
|
*
|
|
* `isReused` only affects diagnostics/log labelling; lifecycle correctness
|
|
* is governed entirely by the reference-counted connection descriptor
|
|
* (sshConnectionPool.cjs). The stream "close"/transport handlers always
|
|
* release this session's hold via releaseConnectionRef, which tears the
|
|
* shared transport down only when the last channel is gone.
|
|
*/
|
|
function setupShellSession({
|
|
conn,
|
|
stream,
|
|
options,
|
|
sessionId,
|
|
event,
|
|
log,
|
|
detachX11Forwarding,
|
|
chainConnections,
|
|
isReused,
|
|
}) {
|
|
const session = {
|
|
conn,
|
|
stream,
|
|
// Only the owning (fresh) session is responsible for the jump-host
|
|
// chain; reused channels share it via the connRef descriptor and must
|
|
// not carry their own copy (otherwise closing a reused tab would end
|
|
// the chain out from under its siblings).
|
|
chainConnections: isReused ? [] : chainConnections,
|
|
webContentsId: event.sender.id,
|
|
// Store connection info for MCP host discovery
|
|
hostname: options.host || options.hostname || '',
|
|
username: options.username || '',
|
|
label: options.label || '',
|
|
// Host file-transfer preference for session-backed SFTP/SCP opens
|
|
// (Catty/MCP/clipboard paste call openSftpForSession without protocol).
|
|
sftpFileProtocol: options.sftpFileProtocol || options.fileProtocol || 'auto',
|
|
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
|
|
? options.sudoAutofillPassword
|
|
: undefined,
|
|
lastIdlePrompt: '',
|
|
lastIdlePromptAt: 0,
|
|
_promptTrackTail: '',
|
|
// SSH server identification string (the `software` part of
|
|
// `SSH-2.0-<software>`). ssh2 captures this during the header
|
|
// exchange and stores it on the client as `_remoteVer` — it
|
|
// is available by the time 'ready' fires, so the renderer can
|
|
// use it to detect network-device vendors without running any
|
|
// additional exec channels. See domain/host.ts
|
|
// `detectVendorFromSshVersion`.
|
|
remoteSshVersion: (conn && typeof conn._remoteVer === 'string') ? conn._remoteVer : '',
|
|
// The actual SSH target this connection authenticated to. Used to make
|
|
// sure a "Copy Tab" reuse opens its channel on a connection going to the
|
|
// *same* host — a saved host edited after the source connected must not
|
|
// silently run commands on the old machine (issue #1204 review).
|
|
_reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options, {
|
|
agentForwarding: options._actualAgentForwarding ?? options.agentForwarding,
|
|
})),
|
|
tcpLatencyDirect:
|
|
!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0
|
|
? !options.proxy
|
|
: false,
|
|
cols: options.cols || 80,
|
|
rows: options.rows || 24,
|
|
};
|
|
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
|
|
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
|
|
if (!claim.ok) {
|
|
const supersededError = new Error("Connection superseded by a newer reconnect");
|
|
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
|
|
try { stream?.close?.(); } catch { /* ignore */ }
|
|
try { if (!isReused) conn?.end?.(); } catch { /* ignore */ }
|
|
throw supersededError;
|
|
}
|
|
openTerminalOutputSession?.(sessionId, event.sender);
|
|
|
|
// Attach the shared connection descriptor to this session. The caller owns
|
|
// the reference *count*: the fresh-connection path calls createConnectionRef
|
|
// after this returns; the reuse path calls acquireConnectionRef *before*
|
|
// issuing the async shell request (so the connection can't be released out
|
|
// from under a pending channel open). We only record the descriptor here.
|
|
if (options._connRef) {
|
|
session.connRef = options._connRef;
|
|
}
|
|
|
|
// Start real-time session log stream if configured. The token is stored
|
|
// on the session so the connection-level error/timeout/close handlers can
|
|
// stop the stream when they clean up the owner session directly.
|
|
let logStreamToken = null;
|
|
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
|
logStreamToken = sessionLogStreamManager.startStream(sessionId, {
|
|
hostLabel: options.hostLabel || options.hostname || '',
|
|
hostname: options.hostname || '',
|
|
directory: options.sessionLog.directory,
|
|
format: options.sessionLog.format || 'txt',
|
|
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
|
|
startTime: Date.now(),
|
|
});
|
|
}
|
|
session._logStreamToken = logStreamToken;
|
|
|
|
// Coalesce shell output and deliver it to the renderer on the next
|
|
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
|
|
// so interactive echo isn't held back by the batch interval. A size
|
|
// cap still forces an immediate flush for bursts of output.
|
|
const {
|
|
bufferData,
|
|
flushPaced: flushBufferPaced,
|
|
takePendingEntry: takePendingBuffer,
|
|
discard: discardBuffer,
|
|
} = createPtyOutputBuffer((data, meta) => {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
const contents = event.sender;
|
|
emitTerminalSessionData(contents, sessionId, data, {
|
|
session,
|
|
cols: session.cols,
|
|
rows: session.rows,
|
|
meta,
|
|
});
|
|
}, {
|
|
onPendingBytesChange: (bytes) => {
|
|
if (sessions.get(sessionId) === session) setBufferedOutputBytes(session, bytes);
|
|
},
|
|
shouldAcceptOutput: () => sessions.get(sessionId) === session && shouldAcceptSessionOutput(session),
|
|
});
|
|
session.flushPendingData = flushBufferPaced;
|
|
session.takePendingData = takePendingBuffer;
|
|
session.discardPendingData = discardBuffer;
|
|
|
|
const getCurrentSessionWebContents = () => {
|
|
const currentId = sessions.get(sessionId)?.webContentsId;
|
|
if (typeof currentId === "number") {
|
|
const current = electronModule?.webContents?.fromId?.(currentId);
|
|
if (current) return current;
|
|
}
|
|
return event.sender;
|
|
};
|
|
const getCurrentSessionWebContentsId = () =>
|
|
getCurrentSessionWebContents()?.id ?? event.sender.id;
|
|
|
|
const sshZmodemSentry = createZmodemSentry({
|
|
sessionId,
|
|
onData(buf) {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
const decoder = getSessionDecoder(sessionId, "stdout");
|
|
const decoded = decoder.write(buf);
|
|
const output = filterTerminalInterruptOutput(session, decoded);
|
|
if (!output.accepted) {
|
|
logTerminalInterruptDrainDropSample(session, {
|
|
sessionId,
|
|
stream: "stdout",
|
|
droppedBytes: output.droppedBytes,
|
|
reason: output.reason,
|
|
accepted: false,
|
|
});
|
|
return;
|
|
}
|
|
if (output.droppedBytes > 0) {
|
|
logTerminalInterruptDrainDropSample(session, {
|
|
sessionId,
|
|
stream: "stdout",
|
|
droppedBytes: output.droppedBytes,
|
|
reason: output.reason,
|
|
accepted: true,
|
|
});
|
|
}
|
|
if (!output.data) return;
|
|
const outputMeta = takePendingInterruptOutputMeta(session);
|
|
trackSessionIdlePrompt(session, output.data);
|
|
bufferData(output.data, outputMeta);
|
|
sessionLogStreamManager.appendData(sessionId, output.data);
|
|
},
|
|
writeToRemote(buf) {
|
|
try { return stream.write(buf); } catch { return true; /* ignore */ }
|
|
},
|
|
waitForTransportDrain(drainOpts = {}) {
|
|
// ssh2 buffers up to its 2 MiB channel window before write() returns
|
|
// false. Watch writableLength progress so healthy slow links can take
|
|
// longer than one timeout window while a fully stalled peer is bounded.
|
|
return waitForWritableDrain(stream, {
|
|
...drainOpts,
|
|
progressIntervalMs: 1000,
|
|
// ssh2 keeps writableLength unchanged until one queued write fully
|
|
// completes, but shrinks _chunk on each channel-window adjustment.
|
|
// Include both so partial frame delivery counts as progress.
|
|
getProgressValue: () => (
|
|
(Number(stream.writableLength) || 0) + (stream._chunk?.length || 0)
|
|
),
|
|
});
|
|
},
|
|
interruptRemote() {
|
|
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
|
},
|
|
probeReceiveConflicts(names, { signal } = {}) {
|
|
return probeReceiveConflicts(sessions.get(sessionId), names, { signal });
|
|
},
|
|
removeRemoteFiles(paths, { signal } = {}) {
|
|
return removeRemoteFiles(sessions.get(sessionId), paths, { signal });
|
|
},
|
|
restoreRemoteModes(entries, { signal } = {}) {
|
|
return restoreRemoteModes(sessions.get(sessionId), entries, { signal });
|
|
},
|
|
requestOverwriteDecision(filename, { signal } = {}) {
|
|
return new Promise((resolve) => {
|
|
const requestId = randomUUID();
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
try { signal?.removeEventListener("abort", onAbort); } catch { /* ignore */ }
|
|
zmodemOverwritePending.delete(requestId);
|
|
};
|
|
const finish = (decision) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(decision);
|
|
};
|
|
const onAbort = () => finish({ action: "cancel", applyToRest: false });
|
|
const timer = setTimeout(() => {
|
|
finish({ action: "skip", applyToRest: false });
|
|
}, 120000);
|
|
zmodemOverwritePending.set(requestId, (payload) => finish({
|
|
action: payload.action,
|
|
applyToRest: !!payload.applyToRest,
|
|
}));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
safeSend(getCurrentSessionWebContents(), "netcatty:zmodem:overwrite-request", {
|
|
sessionId, requestId, filename,
|
|
});
|
|
});
|
|
},
|
|
getWebContents() {
|
|
return getCurrentSessionWebContents();
|
|
},
|
|
selectUploadFiles: selectZmodemUploadFiles
|
|
? () => selectZmodemUploadFiles(getCurrentSessionWebContentsId(), sessionId)
|
|
: undefined,
|
|
selectDownloadDirectory: selectZmodemDownloadDirectory
|
|
? () => selectZmodemDownloadDirectory(getCurrentSessionWebContentsId(), sessionId)
|
|
: undefined,
|
|
label: "SSH",
|
|
});
|
|
session.zmodemSentry = sshZmodemSentry;
|
|
|
|
stream.on("data", (data) => {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
if (!shouldProcessSessionOutput(session, sshZmodemSentry)) {
|
|
logTerminalOutputDropSample(session, {
|
|
sessionId,
|
|
stream: "stdout",
|
|
bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data)),
|
|
});
|
|
return;
|
|
}
|
|
if (session.blockUntargetedCwdProbe && session.pendingCwdRecoveryAfterUserCommand) {
|
|
session.pendingCwdRecoveryAfterUserCommand = false;
|
|
session.allowCwdRecovery = true;
|
|
}
|
|
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
|
|
// In normal mode, sentry's onData callback handles decoding and buffering.
|
|
sshZmodemSentry.consume(data);
|
|
});
|
|
|
|
stream.stderr?.on("data", (data) => {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
if (!shouldProcessSessionOutput(session)) {
|
|
logTerminalOutputDropSample(session, {
|
|
sessionId,
|
|
stream: "stderr",
|
|
bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data)),
|
|
});
|
|
return;
|
|
}
|
|
// stderr is not used for ZMODEM — decode normally
|
|
const decoder = getSessionDecoder(sessionId, "stderr");
|
|
const decoded = decoder.write(data);
|
|
const output = filterTerminalInterruptOutput(session, decoded);
|
|
if (!output.accepted) {
|
|
logTerminalInterruptDrainDropSample(session, {
|
|
sessionId,
|
|
stream: "stderr",
|
|
droppedBytes: output.droppedBytes,
|
|
reason: output.reason,
|
|
accepted: false,
|
|
});
|
|
return;
|
|
}
|
|
if (output.droppedBytes > 0) {
|
|
logTerminalInterruptDrainDropSample(session, {
|
|
sessionId,
|
|
stream: "stderr",
|
|
droppedBytes: output.droppedBytes,
|
|
reason: output.reason,
|
|
accepted: true,
|
|
});
|
|
}
|
|
if (!output.data) return;
|
|
const outputMeta = takePendingInterruptOutputMeta(session);
|
|
bufferData(output.data, outputMeta);
|
|
sessionLogStreamManager.appendData(sessionId, output.data);
|
|
});
|
|
|
|
// Capture the real exit code from the remote process.
|
|
// "exit" fires when the remote shell/process exits normally;
|
|
// "close" fires whenever the channel closes (could be network drop).
|
|
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
|
// code and no signal. Signal terminations (e.g. server kill, idle
|
|
// timeout) have code=null and signal set — those are not user exits.
|
|
let streamExitCode = 0;
|
|
let streamExited = false;
|
|
stream.on("exit", (code, signal) => {
|
|
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
|
|
streamExitCode = typeof code === "number" ? code : 0;
|
|
streamExited = typeof code === "number" && !signal;
|
|
});
|
|
|
|
let closeFinalized = false;
|
|
stream.on("close", () => {
|
|
log("shell stream closed", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
streamExitCode,
|
|
streamExited,
|
|
reused: !!isReused,
|
|
transportError: sessions.get(sessionId)?._transportError,
|
|
});
|
|
const finalizeClose = () => {
|
|
if (closeFinalized) return;
|
|
closeFinalized = true;
|
|
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
|
if (detachX11Forwarding) {
|
|
detachX11Forwarding();
|
|
detachX11Forwarding = null;
|
|
}
|
|
|
|
// Only send exit if session hasn't already been cleaned up by
|
|
// conn.once("close") — which fires before stream.on("close")
|
|
// in ssh2 when the transport drops.
|
|
const liveSession = sessions.get(sessionId);
|
|
if (liveSession === session) {
|
|
const contents = event.sender;
|
|
const transportError = liveSession?._transportError;
|
|
if (liveSession?.closed) {
|
|
// Explicit close already notified every attached renderer.
|
|
} else if (transportError) {
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
|
sessionId,
|
|
exitCode: 1,
|
|
error: transportError,
|
|
reason: "error",
|
|
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
|
});
|
|
} else {
|
|
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
|
// signal) — identical to a user-typed `exit` by code/signal —
|
|
// so detect it via the banner the shell prints just before
|
|
// exiting and report it as a timeout. That keeps the tab open
|
|
// for reconnect instead of auto-closing it (#1062 / #977).
|
|
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
|
|
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
|
sessionId,
|
|
exitCode: streamExitCode,
|
|
reason,
|
|
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
|
});
|
|
}
|
|
liveSession?.zmodemSentry?.cancel();
|
|
// Release this channel's hold on the shared connection. The transport
|
|
// (and any jump-host chain) is only ended once the last channel is
|
|
// gone, so closing a reused tab — or the original tab while a copy is
|
|
// still open — leaves the siblings connected.
|
|
releaseConnectionRef(liveSession);
|
|
closeTerminalOutputSession?.(sessionId);
|
|
sessions.delete(sessionId);
|
|
sessionEncodings.delete(sessionId);
|
|
sessionDecoders.delete(sessionId);
|
|
}
|
|
};
|
|
flushBufferPaced(finalizeClose);
|
|
});
|
|
|
|
// Pre-seed encoding from host charset if it's a GB variant. Seed BOTH the
|
|
// output decoder (sessionEncodings) and the terminal input encoder
|
|
// (session.encoding, read by terminalBridge.writeToSession) so they agree
|
|
// from the first byte — otherwise a GB18030 host decoded output as GB18030
|
|
// while encoding keystrokes as UTF-8 until the user re-picked the encoding
|
|
// (issue #1216). The gate matches the renderer's two-value encoding state
|
|
// (Terminal.tsx) so behavior for other/arbitrary charsets is unchanged:
|
|
// the renderer pushes the effective encoding via setEncoding on attach,
|
|
// and that handler keeps both halves in sync.
|
|
const initialEncoding = normalizeTerminalEncoding(options.charset);
|
|
if (initialEncoding === "gb18030") {
|
|
sessionEncodings.set(sessionId, "gb18030");
|
|
session.encoding = "gb18030";
|
|
}
|
|
|
|
// Run startup command if specified. Encode it with the same charset the
|
|
// interactive input path uses (issue #1216) so a startup command with
|
|
// non-ASCII characters reaches a GB18030 host correctly too.
|
|
if (options.startupCommand) {
|
|
setTimeout(() => {
|
|
stream.write(encodeTerminalInput(`${options.startupCommand}\n`, session.encoding));
|
|
}, 300);
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Open a new interactive shell channel on an already-authenticated SSH
|
|
* connection borrowed from `sourceSession`, instead of dialing a fresh
|
|
* connection. This is what makes "Copy Tab" skip a second MFA prompt
|
|
* (issue #1204): the SSH transport and its authentication are reused; only
|
|
* a new session channel is requested.
|
|
*
|
|
* Resolves with `{ sessionId }` on success. Throws on failure so the caller
|
|
* can fall back to a normal fresh connection.
|
|
*/
|
|
async function openReusedShellSerialized(
|
|
event,
|
|
options,
|
|
sourceSession,
|
|
sessionId,
|
|
log,
|
|
connRef,
|
|
refHolder,
|
|
reuseOpts = {},
|
|
) {
|
|
const cols = options.cols || 80;
|
|
const rows = options.rows || 24;
|
|
const sender = event.sender;
|
|
const conn = sourceSession.conn;
|
|
// Bastions (jumpHosts or direct targets) rate-limit rapid session channel
|
|
// opens ("channelOpen too offen"). Opening a discovery exec *before* the
|
|
// shell burns that budget and Copy Tab falls back to a fresh login
|
|
// (issue #2704). Open the shell first; discover shellPid afterwards.
|
|
const configuredBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
|
|
const discoveryBackoffMs = Number.isFinite(configuredBackoffMs) && configuredBackoffMs > 0
|
|
? configuredBackoffMs
|
|
: 150;
|
|
const shellPidsBeforeOpen = [...sessions.values()]
|
|
.filter((candidate) => candidate?.connRef === connRef && candidate.shellPid)
|
|
.map((candidate) => String(candidate.shellPid));
|
|
|
|
log("reusing existing connection for new shell channel", {
|
|
sessionId,
|
|
sourceSessionId: options.sourceSessionId,
|
|
hostname: options.hostname,
|
|
});
|
|
|
|
const sendProgress = (status, error) => {
|
|
if (!sender.isDestroyed()) {
|
|
sender.send("netcatty:chain:progress", {
|
|
sessionId, hop: 1, total: 1, label: options.hostname, status, error,
|
|
});
|
|
}
|
|
};
|
|
|
|
sendProgress('shell');
|
|
|
|
const shellOptions = {
|
|
env: {
|
|
COLORTERM: "truecolor",
|
|
...(options.env || {}),
|
|
},
|
|
};
|
|
|
|
// Pin the shared connection *before* issuing the async shell request.
|
|
// Otherwise, if the source tab is closed while conn.shell() is pending,
|
|
// releaseConnectionRef could drop the count to zero and end the connection
|
|
// out from under the channel we're opening. We hold a temporary session
|
|
// object as the ref holder, then hand the ref over to the real session
|
|
// once the channel opens. On any failure we release this hold so the count
|
|
// is restored.
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let onConnError = null;
|
|
let abortOpenedStream = null;
|
|
const retryAbortController = new AbortController();
|
|
const pendingBootSignal = options._passphraseSignal || null;
|
|
const abortRetryFromPendingBoot = () => {
|
|
retryAbortController.abort(
|
|
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
|
);
|
|
abortOpenedStream?.(
|
|
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
|
);
|
|
};
|
|
const cleanupConnectionGuard = () => {
|
|
if (onConnError) conn.removeListener("error", onConnError);
|
|
};
|
|
const cleanupPendingBootGuard = () => {
|
|
abortOpenedStream = null;
|
|
pendingBootSignal?.removeEventListener?.("abort", abortRetryFromPendingBoot);
|
|
};
|
|
const cleanupReuseGuards = () => {
|
|
cleanupConnectionGuard();
|
|
cleanupPendingBootGuard();
|
|
};
|
|
|
|
if (pendingBootSignal?.aborted) {
|
|
abortRetryFromPendingBoot();
|
|
} else {
|
|
pendingBootSignal?.addEventListener?.("abort", abortRetryFromPendingBoot, { once: true });
|
|
}
|
|
|
|
const failReuse = (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanupReuseGuards();
|
|
retryAbortController.abort(err);
|
|
// Release the hold we took up-front so the source's reference count is
|
|
// not leaked when we fall back to a fresh connection.
|
|
releaseConnectionRef(refHolder);
|
|
reject(err);
|
|
};
|
|
|
|
// If the borrowed connection dies before the channel opens, surface it
|
|
// as a normal failure so the caller's catch can fall back to a fresh
|
|
// connection. Removed once the channel opens so we don't leave a stray
|
|
// listener on the shared connection (the owner's own error handler stays
|
|
// responsible thereafter).
|
|
onConnError = (connErr) => {
|
|
failReuse(connErr);
|
|
};
|
|
conn.once("error", onConnError);
|
|
|
|
if (
|
|
connRef.allowShellReuse === false
|
|
|| Number(connRef.pendingAbandonedShellOpens) > 0
|
|
) {
|
|
conn.removeListener("error", onConnError);
|
|
failReuse(new Error("Transport is no longer reusable for shells"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const rateLimitBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
|
|
openBoundedSshShellCallback(
|
|
conn,
|
|
{
|
|
term: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
},
|
|
shellOptions,
|
|
(err, stream) => {
|
|
cleanupConnectionGuard();
|
|
if (settled) {
|
|
// Connection already failed; close any channel that still opened
|
|
// and drop the hold (failReuse already released, so guard with the
|
|
// settled check above means we only get here post-failure).
|
|
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
|
return;
|
|
}
|
|
if (err) {
|
|
log("reused shell open failed", { sessionId, hostname: options.hostname, error: err.message });
|
|
sendProgress('error', `Failed to open shell: ${err.message}`);
|
|
failReuse(err);
|
|
return;
|
|
}
|
|
if (connRef.allowShellReuse === false) {
|
|
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
|
failReuse(new Error("Transport is no longer reusable for shells"));
|
|
return;
|
|
}
|
|
|
|
sendProgress('connected');
|
|
|
|
abortOpenedStream = (abortError) => {
|
|
try { stream.close(); } catch { /* ignore */ }
|
|
failReuse(abortError);
|
|
};
|
|
if (pendingBootSignal?.aborted) {
|
|
abortOpenedStream(
|
|
pendingBootSignal.reason || new Error("SSH session start was cancelled"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const finishReusedShellOpen = (prefetchedChunks = []) => {
|
|
cleanupPendingBootGuard();
|
|
if (settled) {
|
|
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
|
return;
|
|
}
|
|
|
|
// Hand the up-front lease over to the real session without changing
|
|
// the lease count (transferConnectionRef). setupShellSession still
|
|
// records connRef; transfer rebinds _sshTransportLeaseId so a later
|
|
// releaseConnectionRef(session) returns the right lease.
|
|
try {
|
|
setupShellSession({
|
|
conn,
|
|
stream,
|
|
options: { ...options, _connRef: connRef },
|
|
sessionId,
|
|
event,
|
|
log,
|
|
detachX11Forwarding: null,
|
|
chainConnections: [],
|
|
isReused: true,
|
|
});
|
|
} catch (setupErr) {
|
|
// openBoundedSshShellCallback delivers this from a Promise .then
|
|
// without catching callback throws — reject via failReuse.
|
|
failReuse(setupErr);
|
|
return;
|
|
}
|
|
if (prefetchedChunks.length > 0) {
|
|
for (const chunk of prefetchedChunks) {
|
|
stream.emit("data", chunk);
|
|
}
|
|
}
|
|
const reconnectAfterLastShellClose =
|
|
consumePendingShellReconnectRisk(connRef);
|
|
const copiedSession = sessions.get(sessionId);
|
|
if (copiedSession && reconnectAfterLastShellClose) {
|
|
copiedSession.blockUntargetedCwdProbe = true;
|
|
copiedSession.parkedReconnectRisk = reconnectAfterLastShellClose;
|
|
}
|
|
if (copiedSession) {
|
|
if (typeof transferConnectionRef === "function") {
|
|
transferConnectionRef(refHolder, copiedSession);
|
|
} else {
|
|
// Legacy count model: detach holder without decrement.
|
|
refHolder.connRef = null;
|
|
}
|
|
} else {
|
|
refHolder.connRef = null;
|
|
}
|
|
|
|
void discoverCopiedShellPid(copiedSession).then((newShellPid) => {
|
|
// Bind PID only to the session this reuse opened. A higher
|
|
// bootEpoch reconnect may already own sessionId in the map.
|
|
const liveSession = sessions.get(sessionId);
|
|
if (liveSession && liveSession === copiedSession && newShellPid) {
|
|
liveSession.shellPid = newShellPid;
|
|
}
|
|
settled = true;
|
|
resolve({ sessionId });
|
|
});
|
|
};
|
|
|
|
const discoverCopiedShellPid = async (copiedSession) => {
|
|
if (options.skipShellPidDiscovery) return null;
|
|
const liveBaseline = () => [...sessions.values()]
|
|
.filter((candidate) => (
|
|
candidate?.connRef === connRef
|
|
&& candidate !== copiedSession
|
|
&& candidate.shellPid
|
|
))
|
|
.map((candidate) => String(candidate.shellPid));
|
|
const listUnassignedSiblings = () => [...sessions.values()].filter(
|
|
(candidate) => (
|
|
candidate?.connRef === connRef
|
|
&& candidate !== copiedSession
|
|
&& !candidate.shellPid
|
|
),
|
|
);
|
|
// Prefer PIDs already recorded on sibling tabs of this shared
|
|
// transport. Fall back to the pre-shell snapshot when the source
|
|
// closed before discovery runs but had a known shellPid.
|
|
let baseline = liveBaseline();
|
|
if (baseline.length === 0) {
|
|
baseline = shellPidsBeforeOpen;
|
|
}
|
|
// Also reconcile when some siblings are already tracked but the
|
|
// copy source (or another tab) still lacks shellPid — otherwise
|
|
// waitForNew sees multiple "new" PIDs and returns null.
|
|
const needsUntrackedReconcile = listUnassignedSiblings().length > 0;
|
|
const blockedEndpointSibling = !options.sourceSessionId
|
|
&& listUnassignedSiblings().some(
|
|
(candidate) => candidate.blockUntargetedCwdProbe === true,
|
|
);
|
|
// Idle-park reconnect after the last interactive shell closed:
|
|
// no sibling tabs share this transport, so post-open discovery
|
|
// cannot disambiguate anything. Skip the exec — bastions often
|
|
// tear down the interactive session when a second channel opens,
|
|
// racing start completion as
|
|
// "Terminal session closed before its output route opened" (#2923).
|
|
// Copy Tab (sourceSessionId) still needs discovery even when the
|
|
// source closes mid-open and leaves an empty baseline.
|
|
if (
|
|
baseline.length === 0
|
|
&& !options.sourceSessionId
|
|
&& (!needsUntrackedReconcile || blockedEndpointSibling)
|
|
) {
|
|
if (copiedSession && blockedEndpointSibling) {
|
|
copiedSession.blockUntargetedCwdProbe = true;
|
|
copiedSession.parkedReconnectRisk = {
|
|
oldShellPids: [],
|
|
hasUnknownOldShell: true,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
if (baseline.length === 0 || needsUntrackedReconcile) {
|
|
const discovery = await listInteractiveShellPidsResilient(conn, {
|
|
initialDelayMs: discoveryBackoffMs,
|
|
attempts: 4,
|
|
backoffMs: discoveryBackoffMs,
|
|
});
|
|
if (!discovery.available && !discovery.rateLimited) {
|
|
// Discovery is permanently unavailable (not rate-limited).
|
|
return null;
|
|
}
|
|
if (discovery.available && discovery.pids.length > 0) {
|
|
const assignedPids = new Set(liveBaseline());
|
|
for (const pid of baseline) assignedPids.add(String(pid));
|
|
const unclaimed = discovery.pids.filter((pid) => !assignedPids.has(pid));
|
|
const unassignedSiblings = listUnassignedSiblings();
|
|
if (unclaimed.length === 1 && unassignedSiblings.length === 1) {
|
|
unassignedSiblings[0].shellPid = unclaimed[0];
|
|
baseline = liveBaseline();
|
|
} else if (unassignedSiblings.length === 0 && unclaimed.length === 1) {
|
|
// Sole unclaimed PID is ambiguous once the source tab is
|
|
// gone: the closing source process may still be listed
|
|
// while the copied shell has not appeared yet. Wait for a
|
|
// PID beyond that candidate; if none appears and the
|
|
// candidate remains the only unclaimed shell, it is the
|
|
// copy (source process already exited).
|
|
const candidatePid = String(unclaimed[0]);
|
|
const waited = await waitForNewInteractiveShellPid(conn, [candidatePid], {
|
|
initialDelayMs: discoveryBackoffMs,
|
|
backoffMs: discoveryBackoffMs,
|
|
});
|
|
if (waited) return waited;
|
|
const recheck = await listInteractiveShellPids(conn);
|
|
if (!recheck.available) return null;
|
|
const assigned = new Set(liveBaseline());
|
|
const remaining = recheck.pids
|
|
.map(String)
|
|
.filter((pid) => !assigned.has(pid));
|
|
if (remaining.length === 1) return remaining[0];
|
|
return null;
|
|
} else if (unassignedSiblings.length === 1 && unclaimed.length === 2) {
|
|
// Source never recorded shellPid (e.g. OSC 7 cwd skipped
|
|
// the probe), so the first post-open scan already lists
|
|
// both shared shells. Reintroducing a pre-open exec would
|
|
// burn bastion channel budget (#2704). Disambiguate by
|
|
// process age (etimes): login shells on one transport are
|
|
// created sequentially, so the copied shell is younger.
|
|
// Numeric PID order is not a timestamp and fails when the
|
|
// PID allocator wraps between source and copy.
|
|
const ages = discovery.ages || {};
|
|
const left = String(unclaimed[0]);
|
|
const right = String(unclaimed[1]);
|
|
const leftAge = ages[left];
|
|
const rightAge = ages[right];
|
|
if (
|
|
Number.isFinite(leftAge)
|
|
&& Number.isFinite(rightAge)
|
|
&& leftAge !== rightAge
|
|
) {
|
|
const older = leftAge > rightAge ? left : right;
|
|
const newer = older === left ? right : left;
|
|
unassignedSiblings[0].shellPid = older;
|
|
return newer;
|
|
}
|
|
// Ages tied (same etimes second) or unavailable: refuse
|
|
// numeric PID order — after wrap the lower PID can be the
|
|
// copy. Leave the pair unassigned and keep only already-
|
|
// tracked PIDs in the baseline so waitForNew can still
|
|
// claim the copy if one of the two later disappears.
|
|
baseline = [...assignedPids];
|
|
} else if (assignedPids.size > 0) {
|
|
baseline = [...assignedPids];
|
|
}
|
|
}
|
|
// Empty successful scans (shell not visible yet) must not
|
|
// abort — waitForNew retries until the new shell appears.
|
|
}
|
|
return waitForNewInteractiveShellPid(conn, baseline, {
|
|
// Brief pause after the shell channel so bastion rate limits
|
|
// have a chance to clear before the discovery exec.
|
|
initialDelayMs: discoveryBackoffMs,
|
|
backoffMs: discoveryBackoffMs,
|
|
});
|
|
};
|
|
|
|
// Decide at channel-open time, not when start() was queued.
|
|
// Copy Tab can lose its source shell while this open is still
|
|
// pinned; pendingShellReconnectRisk is recorded then (#2923).
|
|
const confirmReusedShellLiveness = reuseOpts.confirmReusedShellLiveness === true
|
|
|| shouldConfirmReusedShellLiveness({
|
|
state: connRef?.state,
|
|
pendingShellReconnectRisk: connRef?.pendingShellReconnectRisk,
|
|
remoteSshVersion: conn?._remoteVer,
|
|
});
|
|
if (!confirmReusedShellLiveness) {
|
|
finishReusedShellOpen();
|
|
return;
|
|
}
|
|
|
|
// Idle-park reconnect on an unknown / non-multiplex banner: the
|
|
// channel can open and then immediately exit 0 (齐治 TERM-SSHD,
|
|
// issue #2923). Fail reuse before setupShellSession so start()
|
|
// can discard the parked transport and dial fresh.
|
|
void waitForReusedShellLiveness(stream, {
|
|
settleMs: resolveReusedShellLivenessMs(options.sshReusedShellLivenessMs),
|
|
setTimeout,
|
|
clearTimeout,
|
|
}).then((liveness) => {
|
|
if (settled) {
|
|
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
|
return;
|
|
}
|
|
if (!liveness.alive) {
|
|
log("reused parked shell closed immediately, discarding transport", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
reason: liveness.reason,
|
|
code: liveness.code,
|
|
transportId: connRef?.id,
|
|
});
|
|
if (connRef) {
|
|
connRef.allowIdlePark = false;
|
|
connRef.allowShellReuse = false;
|
|
if (typeof markEndpointNoIdlePark === "function") {
|
|
markEndpointNoIdlePark(connRef.endpoint || connRef.endpointKey);
|
|
}
|
|
}
|
|
try { stream.close(); } catch { /* ignore */ }
|
|
failReuse(new Error("Reused parked shell closed immediately"));
|
|
return;
|
|
}
|
|
finishReusedShellOpen(liveness.buffered);
|
|
}).catch((livenessErr) => {
|
|
failReuse(livenessErr);
|
|
});
|
|
},
|
|
{
|
|
...(Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
|
? { rateLimitBackoffMs }
|
|
: {}),
|
|
...(options.sourceSessionId
|
|
? { rateLimitRetryTimeoutMs: COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS }
|
|
: {}),
|
|
signal: retryAbortController.signal,
|
|
// Cancelling one pending Copy Tab must not destroy the source
|
|
// tab's shared authenticated transport.
|
|
invalidateOnAbort: false,
|
|
onAbandonedOpen: () => {
|
|
connRef.pendingAbandonedShellOpens =
|
|
(Number(connRef.pendingAbandonedShellOpens) || 0) + 1;
|
|
},
|
|
onAbandonedOpenSettled: () => {
|
|
const pending = Math.max(
|
|
0,
|
|
(Number(connRef.pendingAbandonedShellOpens) || 0) - 1,
|
|
);
|
|
if (pending === 0) delete connRef.pendingAbandonedShellOpens;
|
|
else connRef.pendingAbandonedShellOpens = pending;
|
|
},
|
|
},
|
|
);
|
|
} catch (syncErr) {
|
|
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
|
|
// transport dropped between findReusableSession and conn.shell(). Make
|
|
// sure we drop the listener and release the up-front ref so the count
|
|
// isn't leaked, then fall back to a fresh connection.
|
|
log("reused shell threw synchronously", { sessionId, hostname: options.hostname, error: syncErr?.message });
|
|
failReuse(syncErr);
|
|
}
|
|
});
|
|
}
|
|
|
|
function reuseShellSession(event, options, sourceSession, sessionId, log, reuseOpts = {}) {
|
|
const connRef = sourceSession.connRef;
|
|
const {
|
|
ensureConcurrentJoinIdentity = false,
|
|
...openReuseOpts
|
|
} = reuseOpts;
|
|
const refHolder = {};
|
|
// Pin while queued as well as while opening: the source tab may close
|
|
// before this copy reaches the front of the per-connection queue.
|
|
acquireConnectionRef(refHolder, connRef);
|
|
|
|
const previous = connRef.shellOpenQueue || Promise.resolve();
|
|
const operation = previous
|
|
.catch(() => {})
|
|
.then(async () => {
|
|
try {
|
|
if (
|
|
ensureConcurrentJoinIdentity
|
|
&& !await ensureConcurrentJoinShellIdentity(connRef, options)
|
|
) {
|
|
const error = new Error("Concurrent terminal shell identity is unavailable");
|
|
error.code = "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE";
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
releaseConnectionRef(refHolder);
|
|
throw error;
|
|
}
|
|
return openReusedShellSerialized(
|
|
event,
|
|
options,
|
|
sourceSession,
|
|
sessionId,
|
|
log,
|
|
connRef,
|
|
refHolder,
|
|
openReuseOpts,
|
|
);
|
|
});
|
|
const tail = operation.then(() => undefined, () => undefined);
|
|
connRef.shellOpenQueue = tail;
|
|
|
|
return operation.finally(() => {
|
|
if (connRef.shellOpenQueue === tail) {
|
|
delete connRef.shellOpenQueue;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function startSSHSession(event, options) {
|
|
const sessionId = options.sessionId || randomUUID();
|
|
const sender = event.sender;
|
|
const log = createSshDiagnosticLogger(
|
|
!!options.sshDebugLogEnabled || process.env.NETCATTY_SSH_DEBUG === "1",
|
|
);
|
|
const sendConnectionReuseFallback = () => {
|
|
if (!sender.isDestroyed()) {
|
|
sender.send("netcatty:connection-reuse:fallback", {
|
|
sessionId,
|
|
sourceSessionId: options.sourceSessionId,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Resolve forwarding before connection reuse so an agent-provider change
|
|
// (for example Bitwarden becoming available after unlock) cannot attach a
|
|
// new shell to a transport that still forwards the previous socket.
|
|
options = await prepareAgentForwardingOptions(options, getAvailableForwardingAgentSocket);
|
|
|
|
// Connection reuse (issue #1204): when a tab is duplicated we try to open
|
|
// a new shell channel on the source tab's already-authenticated
|
|
// connection. This skips key exchange + authentication entirely, so an
|
|
// MFA-protected host does not prompt for a second factor. Only applies to
|
|
// a live, interactive SSH shell source; anything else falls through to a
|
|
// normal fresh connection below.
|
|
//
|
|
// X11 forwarding is negotiated per shell channel using a fresh fake
|
|
// cookie wired up at connection time, so a reused channel would not carry
|
|
// X11. For X11 hosts we deliberately skip reuse and make a fresh
|
|
// connection so the duplicate keeps working X11 forwarding.
|
|
const reuseEndpoint = buildConnectionReuseEndpoint(options);
|
|
const allowTransportReuse = options.reuseTransport !== false;
|
|
// Copy/Split reuse is source-specific. If that source disappears or its
|
|
// channel cannot open, fall through to a fresh login instead of silently
|
|
// borrowing another live, idle, or in-flight transport for the endpoint.
|
|
const allowGeneralTransportReuse = allowTransportReuse && !options.sourceSessionId;
|
|
const sourceReuseState = options._sourceReuseState;
|
|
const canAttemptSourceReuse = Boolean(
|
|
allowTransportReuse
|
|
&& options.sourceSessionId
|
|
&& !options.x11Forwarding
|
|
&& (!sourceReuseState || sourceReuseState.attempted !== true),
|
|
);
|
|
|
|
if (canAttemptSourceReuse) {
|
|
if (sourceReuseState) sourceReuseState.attempted = true;
|
|
const sourceSession = findReusableSession(sessions, options.sourceSessionId, reuseEndpoint)
|
|
|| (sourceReuseState?.session
|
|
? findReusableSession(
|
|
new Map([[options.sourceSessionId, sourceReuseState.session]]),
|
|
options.sourceSessionId,
|
|
reuseEndpoint,
|
|
)
|
|
: null);
|
|
if (sourceSession) {
|
|
try {
|
|
return await reuseShellSession(event, options, sourceSession, sessionId, log);
|
|
} catch (reuseErr) {
|
|
if (options._passphraseSignal?.aborted) throw reuseErr;
|
|
log("connection reuse failed, falling back to fresh connection", {
|
|
sessionId,
|
|
sourceSessionId: options.sourceSessionId,
|
|
error: reuseErr?.message,
|
|
});
|
|
sendConnectionReuseFallback();
|
|
// Fall through to establish a fresh connection.
|
|
}
|
|
} else {
|
|
log("connection reuse requested but source not reusable, connecting fresh", {
|
|
sessionId,
|
|
sourceSessionId: options.sourceSessionId,
|
|
});
|
|
sendConnectionReuseFallback();
|
|
}
|
|
}
|
|
|
|
// Idle-park / endpoint reuse: after the last tab returns its lease the
|
|
// transport may still be warm. Open a new shell channel without re-auth.
|
|
if (allowGeneralTransportReuse && !options.x11Forwarding && typeof findTransportByEndpoint === "function") {
|
|
// Shell park reuse requires exact agentForwarding match so disabling
|
|
// ForwardAgent cannot reattach to a warm conn that still exposes the agent.
|
|
const parked = findTransportByEndpoint(reuseEndpoint, { kind: "shell" });
|
|
if (parked?.conn && (parked.state === "live" || parked.state === "idle")) {
|
|
try {
|
|
log("reusing parked or shared transport for new shell channel", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
transportId: parked.id,
|
|
transportState: parked.state,
|
|
});
|
|
const confirmReusedShellLiveness = shouldConfirmReusedShellLiveness({
|
|
state: parked.state,
|
|
pendingShellReconnectRisk: parked.pendingShellReconnectRisk,
|
|
remoteSshVersion: parked.conn?._remoteVer,
|
|
});
|
|
return await reuseShellSession(
|
|
event,
|
|
options,
|
|
{
|
|
conn: parked.conn,
|
|
connRef: parked,
|
|
// openReusedShellSerialized only needs conn + connRef; stream is
|
|
// required by findReusableSession but we bypass that path here.
|
|
stream: {},
|
|
},
|
|
sessionId,
|
|
log,
|
|
{ confirmReusedShellLiveness },
|
|
);
|
|
} catch (parkErr) {
|
|
if (options._passphraseSignal?.aborted) {
|
|
throw options._passphraseSignal.reason instanceof Error
|
|
? options._passphraseSignal.reason
|
|
: parkErr;
|
|
}
|
|
log("parked transport reuse failed, falling back to fresh connection", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: parkErr?.message,
|
|
});
|
|
// Fall through to a normal dial.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Atomically reserve the physical dial before any asynchronous key,
|
|
// proxy, or jump-host preparation. A second terminal/SFTP/forward open
|
|
// for the same compatible endpoint can wait for this leader and then
|
|
// open its own channel on the authenticated transport.
|
|
let pendingDialCoordination = options._pendingDialState?.coordination || null;
|
|
if (
|
|
allowGeneralTransportReuse
|
|
&& !pendingDialCoordination
|
|
&& !options.x11Forwarding
|
|
&& typeof beginTransportDial === "function"
|
|
) {
|
|
const coordination = beginTransportDial(reuseEndpoint, { kind: "shell" });
|
|
if (coordination.role === "reuse" || coordination.role === "join") {
|
|
try {
|
|
const transport = coordination.role === "reuse"
|
|
? coordination.transport
|
|
: await waitForTransportDial(coordination);
|
|
return await reuseShellSession(
|
|
event,
|
|
options,
|
|
{ conn: transport.conn, connRef: transport, stream: {} },
|
|
sessionId,
|
|
log,
|
|
{
|
|
ensureConcurrentJoinIdentity: coordination.role === "join"
|
|
&& coordination._record?.kind === "shell",
|
|
},
|
|
);
|
|
} catch (coordinationErr) {
|
|
if (coordinationErr?.code === "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE") {
|
|
log("concurrent transport shell identity unavailable; connecting fresh", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
});
|
|
} else {
|
|
if (options._passphraseSignal?.aborted) {
|
|
throw options._passphraseSignal.reason instanceof Error
|
|
? options._passphraseSignal.reason
|
|
: coordinationErr;
|
|
}
|
|
// A waiter must observe the leader's real failure instead of
|
|
// immediately starting a second authentication prompt. Existing
|
|
// transport reuse keeps its historical fresh-dial fallback.
|
|
if (coordination.role === "join") throw coordinationErr;
|
|
log("coordinated transport reuse failed, connecting fresh", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: coordinationErr?.message,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
pendingDialCoordination = coordination;
|
|
if (options._pendingDialState) {
|
|
options._pendingDialState.coordination = coordination;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options._passphraseSignal?.aborted) {
|
|
throw options._passphraseSignal.reason instanceof Error
|
|
? options._passphraseSignal.reason
|
|
: new Error("SSH session start was cancelled");
|
|
}
|
|
|
|
const cols = options.cols || 80;
|
|
const rows = options.rows || 24;
|
|
|
|
const sendProgress = (hop, total, label, status, error) => {
|
|
if (!sender.isDestroyed()) {
|
|
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
|
|
}
|
|
};
|
|
|
|
try {
|
|
const { tcpConnectTimeoutMs, authReadyTimeoutMs } = resolveSshConnectionTimeouts(options);
|
|
log("session starting", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
username: options.username || "root",
|
|
hostLabel: options.hostLabel || options.label,
|
|
hasJumpHosts: (options.jumpHosts || []).length > 0,
|
|
hasProxy: !!options.proxy,
|
|
tcpConnectTimeoutMs,
|
|
authReadyTimeoutMs,
|
|
});
|
|
const conn = new SSHClient();
|
|
let chainConnections = [];
|
|
let connectionSocket = null;
|
|
|
|
// Determine if we have jump hosts
|
|
const jumpHosts = options.jumpHosts || [];
|
|
const hasJumpHosts = jumpHosts.length > 0;
|
|
const hasProxy = !!options.proxy;
|
|
const totalHops = jumpHosts.length + 1; // +1 for final target
|
|
|
|
// Build base connection options for final target
|
|
const keepalivePolicy = resolveConnectionKeepalivePolicy(options);
|
|
const connectOpts = {
|
|
host: options.hostname,
|
|
port: options.port || 22,
|
|
username: options.username || "root",
|
|
// `timeout` covers TCP dial silence; `readyTimeout` covers the full
|
|
// SSH handshake/auth flow so MFA still has enough time.
|
|
timeout: tcpConnectTimeoutMs,
|
|
// ssh2 starts readyTimeout before TCP connects. The auth-ready timer
|
|
// below starts explicitly from the connection event instead.
|
|
readyTimeout: 0,
|
|
// Resolved keepalive (caller decides whether host override or global
|
|
// applies). interval is in seconds; 0 means truly disabled, so
|
|
// countMax also goes to 0 to skip ssh2's dead-connection check.
|
|
keepaliveInterval: keepalivePolicy.keepaliveIntervalMs,
|
|
keepaliveCountMax: keepalivePolicy.keepaliveCountMax,
|
|
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
|
tryKeyboard: true,
|
|
algorithms: buildAlgorithms(options.legacyAlgorithms, {
|
|
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
|
algorithmOverrides: options.algorithmOverrides,
|
|
}),
|
|
};
|
|
attachSshDebugLogger(connectOpts, log);
|
|
logSshAlgorithms("Target host", connectOpts.algorithms, {
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
legacyAlgorithms: !!options.legacyAlgorithms,
|
|
skipEcdsaHostKey: !!options.skipEcdsaHostKey,
|
|
hasAlgorithmOverrides: !!options.algorithmOverrides,
|
|
}, log);
|
|
|
|
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
|
|
sender,
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
knownHosts: options.knownHosts,
|
|
verifyHostKeys: options.verifyHostKeys,
|
|
bootEpoch: options.bootEpoch,
|
|
});
|
|
|
|
// Authentication for final target
|
|
const hasCertificate = options.authMethod !== "password"
|
|
&& typeof options.certificate === "string"
|
|
&& options.certificate.trim().length > 0;
|
|
const isAutomaticAuth = options.authMethod === "auto";
|
|
const isSelectedKeyAuth = options.authMethod === "key" || options.authMethod === "certificate";
|
|
const effectivePassphrase = options.passphrase;
|
|
|
|
console.log("[SSH] Auth configuration:", {
|
|
hasCertificate,
|
|
keySource: options.keySource,
|
|
hasPublicKey: !!options.publicKey,
|
|
hasPrivateKey: !!options.privateKey,
|
|
hasPassword: !!options.password,
|
|
hasEffectivePassphrase: !!effectivePassphrase,
|
|
});
|
|
|
|
log("Auth configuration", {
|
|
hasCertificate,
|
|
keySource: options.keySource,
|
|
hasPublicKey: !!options.publicKey,
|
|
hasPrivateKey: !!options.privateKey,
|
|
});
|
|
|
|
let authAgent = null;
|
|
const systemAuthAgent = shouldPrepareSystemAgentForLogin(options)
|
|
? await prepareSystemSshAgentForAuth(options, "[SSH]")
|
|
: null;
|
|
// Kick off the default-key scan now so it overlaps the identity-file /
|
|
// inline-key preparation below instead of running serially after it.
|
|
// findAllDefaultPrivateKeys swallows its own fs errors and never rejects,
|
|
// so leaving this promise briefly unawaited cannot surface an unhandled
|
|
// rejection even if the key prep throws first.
|
|
const defaultKeysPromise = findAllDefaultPrivateKeys();
|
|
const identityFile = options.authMethod !== "password" && !options.privateKey && !systemAuthAgent
|
|
? await loadFirstIdentityFileForAuth({
|
|
sender,
|
|
identityFilePaths: options.identityFilePaths,
|
|
hostname: options.hostname,
|
|
initialPassphrase: options.passphrase,
|
|
passphraseSignal: options._passphraseSignal,
|
|
sessionId,
|
|
bootEpoch: options.bootEpoch,
|
|
logPrefix: "[SSH]",
|
|
onPassphrasePromptShown: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
|
|
),
|
|
onPassphrasePromptResolved: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, "auth-attempt", "user responded",
|
|
),
|
|
onLoaded: (loaded) => {
|
|
log("Loaded identity file", { keyPath: loaded.keyPath, encrypted: !!loaded.passphrase });
|
|
},
|
|
onError: (err, keyPath) => {
|
|
log("Failed to read identity file", { keyPath, error: err.message });
|
|
},
|
|
})
|
|
: null;
|
|
const inlineKey = options.authMethod !== "password" && options.privateKey && !systemAuthAgent
|
|
? await preparePrivateKeyForAuth({
|
|
sender,
|
|
privateKey: options.privateKey,
|
|
keyId: options.keyId,
|
|
keyName: options.keyId || options.username,
|
|
hostname: options.hostname,
|
|
initialPassphrase: effectivePassphrase,
|
|
passphraseSignal: options._passphraseSignal,
|
|
sessionId,
|
|
bootEpoch: options.bootEpoch,
|
|
logPrefix: "[SSH]",
|
|
onPassphrasePromptShown: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
|
|
),
|
|
onPassphrasePromptResolved: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, "auth-attempt", "user responded",
|
|
),
|
|
})
|
|
: null;
|
|
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
|
|
const effectiveIdentityPassphrase = inlineKey?.passphrase || identityFile?.passphrase;
|
|
|
|
if (systemAuthAgent) {
|
|
connectOpts.agent = systemAuthAgent;
|
|
}
|
|
|
|
if (hasCertificate) {
|
|
authAgent = new NetcattyAgent({
|
|
mode: "certificate",
|
|
webContents: event.sender,
|
|
meta: {
|
|
label: options.keyId || options.username || "",
|
|
certificate: options.certificate,
|
|
privateKey: effectivePrivateKey,
|
|
passphrase: effectiveIdentityPassphrase,
|
|
},
|
|
});
|
|
connectOpts.agent = authAgent;
|
|
} else if (effectivePrivateKey) {
|
|
connectOpts.privateKey = effectivePrivateKey;
|
|
if (effectiveIdentityPassphrase) {
|
|
connectOpts.passphrase = effectiveIdentityPassphrase;
|
|
}
|
|
}
|
|
|
|
// Whitespace-only passwords are valid SSH secrets (issue #2036).
|
|
if (isPasswordProvided(options.password)) {
|
|
connectOpts.password = options.password;
|
|
}
|
|
|
|
// Default ~/.ssh keys are a fallback for hosts that have no explicit
|
|
// credentials. Password-only hosts must NOT silently fall back to local
|
|
// keys: jump-host / SFTP paths already honor that rule via
|
|
// buildAuthHandler (issue #266), and falling back only on the direct
|
|
// terminal path makes a wrong saved password look fine until ProxyJump
|
|
// or SFTP fails (issue #2079).
|
|
//
|
|
// The full list is scanned exactly once (kicked off above); its first
|
|
// entry is the preferred default key — identical to what a separate
|
|
// findDefaultPrivateKey() scan would return — so derive it here instead
|
|
// of walking ~/.ssh a second time. (Pinned by
|
|
// sshBridge.defaultKeyEquivalence.test.cjs.)
|
|
let usedDefaultKeyAsPrimary = false;
|
|
const discoveredDefaultKeys = await defaultKeysPromise;
|
|
const allDefaultKeys = isSelectedKeyAuth || options.authMethod === "password" || (systemAuthAgent && options.identitiesOnly)
|
|
? []
|
|
: discoveredDefaultKeys;
|
|
const defaultKeyInfo = allDefaultKeys[0] ?? null;
|
|
// Explicit password without a user-configured key/certificate/agent is
|
|
// password-only — same predicate buildAuthHandler uses for isPasswordOnly.
|
|
const isPasswordOnlyAuth =
|
|
options.authMethod === "password" || (
|
|
!isAutomaticAuth &&
|
|
isPasswordProvided(connectOpts.password) &&
|
|
!connectOpts.privateKey &&
|
|
!hasCertificate &&
|
|
!systemAuthAgent &&
|
|
!hasUserConfiguredKey(options)
|
|
);
|
|
if (defaultKeyInfo && !isPasswordOnlyAuth) {
|
|
log("Found default SSH key for fallback", { keyPath: defaultKeyInfo.keyPath, keyName: defaultKeyInfo.keyName });
|
|
} else if (defaultKeyInfo && isPasswordOnlyAuth) {
|
|
log("Skipping default SSH key fallback for password-only auth", {
|
|
keyPath: defaultKeyInfo.keyPath,
|
|
keyName: defaultKeyInfo.keyName,
|
|
});
|
|
}
|
|
|
|
// Use unlocked encrypted keys if provided (from retry after auth failure)
|
|
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
|
|
const unlockedEncryptedKeys = resolveUnlockedEncryptedKeysForAuth(
|
|
options,
|
|
Boolean(isSelectedKeyAuth || (systemAuthAgent && options.identitiesOnly)),
|
|
);
|
|
if (unlockedEncryptedKeys.length > 0) {
|
|
log("Using unlocked encrypted keys from retry", {
|
|
count: unlockedEncryptedKeys.length,
|
|
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
|
});
|
|
}
|
|
|
|
// Automatic mode intentionally mirrors OpenSSH-style discovery even
|
|
// when a saved password exists: agent and default keys are tried first,
|
|
// then the password. Password-only mode never enters this path.
|
|
if (isAutomaticAuth && !connectOpts.agent && options.useSshAgent !== false) {
|
|
const automaticAgentSocket = await getAvailableAgentSocket();
|
|
if (automaticAgentSocket) {
|
|
connectOpts.agent = automaticAgentSocket;
|
|
log("Automatic auth found SSH agent", { agentSocket: automaticAgentSocket });
|
|
}
|
|
}
|
|
|
|
// If no primary auth method configured, try ssh-agent first, then ALL default keys.
|
|
// Skip default-key primaries when the user explicitly chose a key (inline or
|
|
// identityFilePaths) even if loading that key failed (issue #1614).
|
|
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
|
// First, try to use ssh-agent if available (this is what regular SSH does)
|
|
const sshAgentSocket = options.useSshAgent !== false
|
|
? await getAvailableAgentSocket()
|
|
: null;
|
|
|
|
if (sshAgentSocket) {
|
|
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
|
connectOpts.agent = sshAgentSocket;
|
|
}
|
|
|
|
// Mark that we need to try all default keys (handled in authMethods below)
|
|
if (!hasUserConfiguredKey(options) && allDefaultKeys.length > 0) {
|
|
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
|
|
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
|
|
connectOpts.privateKey = allDefaultKeys[0].privateKey;
|
|
usedDefaultKeyAsPrimary = true;
|
|
} else if (allDefaultKeys.length === 0) {
|
|
log("No default SSH key found in ~/.ssh directory");
|
|
}
|
|
}
|
|
|
|
log("Final auth configuration", {
|
|
hasPrivateKey: !!connectOpts.privateKey,
|
|
hasPassword: !!connectOpts.password,
|
|
hasAgent: !!connectOpts.agent,
|
|
hasDefaultKeyFallback: !!defaultKeyInfo && !isPasswordOnlyAuth,
|
|
isPasswordOnlyAuth,
|
|
});
|
|
|
|
// ssh2 uses connectOpts.agent for forwarding, but authHandler agent
|
|
// objects may select a different agent for login.
|
|
const loginAgent = connectOpts.agent;
|
|
|
|
// Agent forwarding
|
|
if (options.agentForwarding) {
|
|
await applyAgentForwarding(options, connectOpts, getAvailableForwardingAgentSocket);
|
|
if (!connectOpts.agentForward) {
|
|
log("Agent forwarding requested but no agent available, skipping");
|
|
}
|
|
}
|
|
|
|
// Build authentication handler with fallback support
|
|
// ssh2 authHandler can be a function that returns the next auth method to try
|
|
|
|
// Check if we have a cached successful auth method for this host
|
|
const cachedMethod = getCachedAuthMethod(connectOpts.username, options.hostname, options.port);
|
|
|
|
// Track which method succeeded for caching
|
|
let lastTriedMethod = null;
|
|
// Shared with keyboard-interactive auto-fill. A completed password or
|
|
// keyboard-interactive factor suppresses reuse of the saved host
|
|
// password for a later KI factor (EDR step-up). publickey partialSuccess
|
|
// still allows Password: auto-fill.
|
|
const authPhase = createAuthPhase();
|
|
|
|
if (authAgent) {
|
|
const order = ["none", "agent"];
|
|
if (options.requiresMfa && !options._skipPasswordMethod) {
|
|
order.push("keyboard-interactive");
|
|
}
|
|
if (connectOpts.password && !options._skipPasswordMethod) {
|
|
order.push("password");
|
|
}
|
|
// Default key fallback only when this is not password-only (issue #266 / #2079).
|
|
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth.
|
|
if (defaultKeyInfo && !hasUserConfiguredKey(options) && !isPasswordOnlyAuth) {
|
|
connectOpts.privateKey = defaultKeyInfo.privateKey;
|
|
order.push("publickey");
|
|
}
|
|
if (!order.includes("keyboard-interactive")) order.push("keyboard-interactive");
|
|
// Function form so authPhase.hadPartialSuccess updates for cert/agent
|
|
// first-factor + keyboard-interactive second-factor (#2150).
|
|
connectOpts.authHandler = createOrderedStringAuthHandler(
|
|
order,
|
|
authPhase,
|
|
undefined,
|
|
authAgent !== connectOpts.agent
|
|
? { username: connectOpts.username, agent: authAgent }
|
|
: undefined,
|
|
);
|
|
connectOpts._shouldRetryKeyboardInteractiveFirst = () => Boolean(authPhase.retryKeyboardInteractiveFirst);
|
|
log("Auth order (agent mode)", { order, skipPasswordMethod: !!options._skipPasswordMethod });
|
|
} else {
|
|
// Build dynamic auth handler for fallback support
|
|
const authMethods = [];
|
|
|
|
if (isAutomaticAuth) {
|
|
if (options.requiresMfa && !connectOpts.password && !options._skipPasswordMethod) {
|
|
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
|
}
|
|
if (shouldOfferAgentForLogin(options, { agent: loginAgent })) {
|
|
authMethods.push({ type: "agent", agent: loginAgent, id: "agent" });
|
|
}
|
|
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
|
|
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
|
|
}
|
|
for (const keyInfo of allDefaultKeys) {
|
|
authMethods.push({
|
|
type: "publickey",
|
|
key: keyInfo.privateKey,
|
|
isDefault: true,
|
|
id: `publickey-default-${keyInfo.keyName}`
|
|
});
|
|
}
|
|
if (options.requiresMfa && connectOpts.password && !options._skipPasswordMethod) {
|
|
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
|
}
|
|
if (connectOpts.password && !options._skipPasswordMethod) {
|
|
authMethods.push({ type: "password", id: "password" });
|
|
}
|
|
} else {
|
|
// First try user-configured key if available (explicit user choice)
|
|
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
|
|
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
|
|
}
|
|
|
|
// Then try agent if configured (try agent before password since it's usually faster)
|
|
if (shouldOfferAgentForLogin(options, { agent: loginAgent })) {
|
|
authMethods.push({ type: "agent", agent: loginAgent, id: "agent" });
|
|
}
|
|
|
|
// MFA/PAM hosts can reject the SSH "password" method while accepting
|
|
// the login password through keyboard-interactive.
|
|
if (options.requiresMfa && !options._skipPasswordMethod) {
|
|
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
|
}
|
|
|
|
// Then try password if available (explicit user choice).
|
|
if (connectOpts.password && !options._skipPasswordMethod) {
|
|
authMethods.push({ type: "password", id: "password" });
|
|
}
|
|
|
|
// Then try ALL default SSH keys as fallback (not just the first one!)
|
|
// Password-only hosts skip automatic default-key probing so terminal/SFTP/jump agree.
|
|
if (!isPasswordOnlyAuth && usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
|
|
for (const keyInfo of allDefaultKeys) {
|
|
authMethods.push({
|
|
type: "publickey",
|
|
key: keyInfo.privateKey,
|
|
isDefault: true,
|
|
id: `publickey-default-${keyInfo.keyName}`
|
|
});
|
|
}
|
|
} else if (!isSelectedKeyAuth && defaultKeyInfo && !hasUserConfiguredKey(options) && !usedDefaultKeyAsPrimary) {
|
|
// Single default key fallback (when user has configured other non-password auth)
|
|
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
|
|
}
|
|
}
|
|
|
|
// Unlocked default keys remain eligible only for automatic and
|
|
// legacy fallback modes. Explicit modes must not probe unrelated keys.
|
|
for (const keyInfo of unlockedEncryptedKeys) {
|
|
authMethods.push({
|
|
type: "publickey",
|
|
key: keyInfo.privateKey,
|
|
passphrase: keyInfo.passphrase,
|
|
isDefault: true,
|
|
id: `publickey-encrypted-${keyInfo.keyName}`
|
|
});
|
|
}
|
|
|
|
// Keyboard-interactive as last resort, or already placed before password
|
|
// as last-resort fallback for multi-factor / EDR.
|
|
if (!authMethods.some((method) => method.type === "keyboard-interactive")) {
|
|
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
|
}
|
|
|
|
log("Auth methods configured", {
|
|
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
|
|
cachedMethod,
|
|
usedDefaultKeyAsPrimary
|
|
});
|
|
|
|
// Reorder methods based on cached successful method
|
|
if (shouldPromoteCachedAuthMethod(options.authMethod, cachedMethod)) {
|
|
const cachedIndex = authMethods.findIndex(m => m.id === cachedMethod);
|
|
if (cachedIndex > 0) {
|
|
const [cachedAuthMethod] = authMethods.splice(cachedIndex, 1);
|
|
authMethods.unshift(cachedAuthMethod);
|
|
log("Reordered auth methods based on cache", {
|
|
methods: authMethods.map(m => m.id)
|
|
});
|
|
}
|
|
}
|
|
|
|
// Always use dynamic authHandler to ensure consistent "none" probing
|
|
// and auth method logging regardless of how many methods are configured
|
|
if (authMethods.length >= 1) {
|
|
// Track methods that have been attempted (to avoid re-trying on failure)
|
|
// This prevents reusing the same key when server requires multiple publickey auth steps
|
|
// and also prevents re-attempting failed methods
|
|
let attemptedMethodIds = new Set();
|
|
// Methods that contributed a successful factor; never retried.
|
|
const succeededMethodIds = new Set();
|
|
// Methods actually rejected by the server. Keep these blocked when
|
|
// a later factor makes previously unavailable methods eligible.
|
|
const failedMethodIds = new Set();
|
|
// Track the first successful method for caching (not the last one in multi-step flows)
|
|
let firstSuccessfulMethod = null;
|
|
// Track if we've gone through a partialSuccess flow (multi-step auth)
|
|
let hadPartialSuccess = false;
|
|
// Some EDR servers advertise keyboard-interactive next to password,
|
|
// then remove it after a rejected password request. The wrapper can
|
|
// recover by retrying once with the password method omitted so the
|
|
// login password flows through keyboard-interactive.
|
|
let passwordAttemptSawKeyboardInteractive = false;
|
|
let shouldRetryKeyboardInteractiveFirst = false;
|
|
|
|
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
|
|
log("authHandler called", { methodsLeft, partialSuccess, attemptedMethodIds: Array.from(attemptedMethodIds) });
|
|
|
|
// Log rejection of previous method
|
|
if (lastTriedMethod && !partialSuccess) {
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
|
|
if (
|
|
lastTriedMethod === "password" &&
|
|
passwordAttemptSawKeyboardInteractive &&
|
|
Array.isArray(methodsLeft) &&
|
|
!methodsLeft.includes("keyboard-interactive")
|
|
) {
|
|
shouldRetryKeyboardInteractiveFirst = true;
|
|
log("password rejection removed keyboard-interactive; scheduling KI-first retry", {
|
|
methodsLeft,
|
|
});
|
|
}
|
|
if (lastTriedMethod !== "none") {
|
|
failedMethodIds.add(lastTriedMethod);
|
|
}
|
|
}
|
|
|
|
// On the very first call (methodsLeft === null), try "none" auth.
|
|
// Per RFC 4252, the "none" request is how the client discovers which
|
|
// methods the server supports. It also allows passwordless login on
|
|
// embedded devices. This matches the behavior of OpenSSH and Tabby.
|
|
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
|
|
attemptedMethodIds.add("none");
|
|
lastTriedMethod = "none";
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
|
|
return callback("none");
|
|
}
|
|
|
|
// methodsLeft can be null on first call (before server responds with available methods)
|
|
// Include "agent" for SSH agent-based auth (used with agentForwarding)
|
|
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
|
|
|
// Handle partialSuccess case (e.g., password succeeded but server requires additional auth like MFA)
|
|
// When partialSuccess is true, we should try the remaining methods the server is asking for
|
|
if (partialSuccess && methodsLeft && methodsLeft.length > 0) {
|
|
hadPartialSuccess = true;
|
|
// password method id is "password"; key ids start with publickey-
|
|
const succeededType =
|
|
lastTriedMethod === "password"
|
|
? "password"
|
|
: lastTriedMethod === "agent"
|
|
? "agent"
|
|
: lastTriedMethod === "keyboard-interactive"
|
|
? "keyboard-interactive"
|
|
: "publickey";
|
|
markAuthPhasePartialSuccess(authPhase, succeededType);
|
|
// Record the first successful method (the one that triggered partialSuccess)
|
|
if (lastTriedMethod && !firstSuccessfulMethod) {
|
|
firstSuccessfulMethod = lastTriedMethod;
|
|
log("Recorded first successful method for caching", { method: firstSuccessfulMethod });
|
|
}
|
|
// Reconsider methods that were unavailable in the previous
|
|
// factor, but preserve credentials already rejected or used.
|
|
if (lastTriedMethod) {
|
|
succeededMethodIds.add(lastTriedMethod);
|
|
log("Recorded successful auth factor (partial success)", { method: lastTriedMethod });
|
|
}
|
|
attemptedMethodIds = new Set([...failedMethodIds, ...succeededMethodIds]);
|
|
// PAM/EDR can require two consecutive keyboard-interactive
|
|
// factors (login password, then a separate secondary password).
|
|
// A partial-success response explicitly advertising KI again is
|
|
// permission to repeat that method with a fresh server prompt.
|
|
// Keep keys/passwords de-duplicated, but do not suppress this
|
|
// second interactive factor (#2150).
|
|
if (
|
|
methodsLeft.includes("keyboard-interactive") &&
|
|
canRepeatKeyboardInteractive(authPhase, failedMethodIds)
|
|
) {
|
|
attemptedMethodIds.delete("keyboard-interactive");
|
|
}
|
|
|
|
log("Partial success - server requires additional auth", { methodsLeft, succeeded: Array.from(succeededMethodIds), attemptedMethodIds: Array.from(attemptedMethodIds) });
|
|
|
|
// Next-factor selection: prefer keyboard-interactive when the
|
|
// server still allows it (automatic KI fallback after any
|
|
// partial success). Otherwise walk authMethods in order.
|
|
const partialCandidates = [];
|
|
for (const matchingMethod of authMethods) {
|
|
if (attemptedMethodIds.has(matchingMethod.id)) continue;
|
|
const serverMethod =
|
|
matchingMethod.type === "agent" || matchingMethod.type === "publickey"
|
|
? "publickey"
|
|
: matchingMethod.type;
|
|
if (!methodsLeft.includes(serverMethod) && !methodsLeft.includes(matchingMethod.type)) {
|
|
continue;
|
|
}
|
|
partialCandidates.push(matchingMethod);
|
|
}
|
|
const preferredPartial =
|
|
partialCandidates.find((method) => method.type === "keyboard-interactive")
|
|
|| partialCandidates[0];
|
|
if (preferredPartial) {
|
|
const matchingMethod = preferredPartial;
|
|
const serverMethod =
|
|
matchingMethod.type === "agent" || matchingMethod.type === "publickey"
|
|
? "publickey"
|
|
: matchingMethod.type;
|
|
log("Found matching method for partial success", { serverMethod, matchingMethod: matchingMethod.id });
|
|
attemptedMethodIds.add(matchingMethod.id);
|
|
lastTriedMethod = matchingMethod.id;
|
|
|
|
if (matchingMethod.type === "keyboard-interactive") {
|
|
log("Trying keyboard-interactive auth (partial success)", { id: matchingMethod.id });
|
|
return callback("keyboard-interactive");
|
|
} else if (matchingMethod.type === "password") {
|
|
log("Trying password auth (partial success)", { id: matchingMethod.id });
|
|
return callback({
|
|
type: "password",
|
|
username: connectOpts.username,
|
|
password: connectOpts.password,
|
|
});
|
|
} else if (matchingMethod.type === "agent") {
|
|
const agentType = typeof matchingMethod.agent === "string" ? "path" : "NetcattyAgent";
|
|
log("Trying agent auth (partial success)", { id: matchingMethod.id, agentType });
|
|
return matchingMethod.agent === connectOpts.agent
|
|
? callback("agent")
|
|
: callback({
|
|
type: "agent",
|
|
username: connectOpts.username,
|
|
agent: matchingMethod.agent,
|
|
});
|
|
} else if (matchingMethod.type === "publickey") {
|
|
log("Trying publickey auth (partial success)", { id: matchingMethod.id });
|
|
return callback({
|
|
type: "publickey",
|
|
username: connectOpts.username,
|
|
key: matchingMethod.key,
|
|
passphrase: matchingMethod.passphrase,
|
|
});
|
|
}
|
|
}
|
|
// No matching method found for partial success
|
|
log("No matching method found for partial success requirements", { methodsLeft });
|
|
return callback(false);
|
|
}
|
|
|
|
for (const method of authMethods) {
|
|
// Skip methods that have already been attempted (e.g., during partial success handling)
|
|
if (attemptedMethodIds.has(method.id)) {
|
|
log("Skipping already attempted method", { method: method.id });
|
|
continue;
|
|
}
|
|
|
|
// Check if this method is still available on server
|
|
// Note: "agent" uses "publickey" as the underlying method type
|
|
const methodName = method.type === "password" ? "password" :
|
|
method.type === "publickey" ? "publickey" :
|
|
method.type === "agent" ? "publickey" : "keyboard-interactive";
|
|
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
|
|
log("Auth method not available on server, skipping", { method: method.id });
|
|
continue;
|
|
}
|
|
|
|
// Mark as attempted BEFORE returning
|
|
attemptedMethodIds.add(method.id);
|
|
lastTriedMethod = method.id;
|
|
|
|
if (method.type === "agent") {
|
|
// Only log safe identifier, not the full agent object which may contain private keys
|
|
const agentType = typeof method.agent === "string" ? "path" : "NetcattyAgent";
|
|
log("Trying agent auth", { id: method.id, agentType });
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
|
|
return method.agent === connectOpts.agent
|
|
? callback("agent")
|
|
: callback({
|
|
type: "agent",
|
|
username: connectOpts.username,
|
|
agent: method.agent,
|
|
});
|
|
} else if (method.type === "publickey") {
|
|
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
|
|
const keyLabel = method.id.startsWith("publickey-default-")
|
|
? `key ${method.id.replace("publickey-default-", "")}`
|
|
: method.id.startsWith("publickey-encrypted-")
|
|
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
|
|
: method.id === "publickey-user"
|
|
? "configured key"
|
|
: method.id;
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
|
|
return callback({
|
|
type: "publickey",
|
|
username: connectOpts.username,
|
|
key: method.key,
|
|
passphrase: method.passphrase,
|
|
});
|
|
} else if (method.type === "password") {
|
|
log("Trying password auth", { id: method.id });
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
|
|
passwordAttemptSawKeyboardInteractive = availableMethods.includes("keyboard-interactive");
|
|
return callback({
|
|
type: "password",
|
|
username: connectOpts.username,
|
|
password: connectOpts.password,
|
|
});
|
|
} else if (method.type === "keyboard-interactive") {
|
|
log("Trying keyboard-interactive auth", { id: method.id });
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
|
|
// Return string instead of object - ssh2 requires a prompt function
|
|
// for keyboard-interactive objects. Returning the string lets ssh2
|
|
// use its default handling and trigger the keyboard-interactive event.
|
|
return callback("keyboard-interactive");
|
|
}
|
|
}
|
|
|
|
log("All auth methods exhausted");
|
|
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
|
|
return callback(false);
|
|
};
|
|
|
|
// Store method reference for success callback
|
|
// For multi-step auth (partialSuccess), cache the first successful method, not the last
|
|
// This ensures next connection starts with the correct first factor
|
|
connectOpts._lastTriedMethodRef = () => {
|
|
if (hadPartialSuccess && firstSuccessfulMethod) {
|
|
log("Using first successful method for cache (multi-step auth)", { firstSuccessfulMethod });
|
|
return firstSuccessfulMethod;
|
|
}
|
|
return lastTriedMethod;
|
|
};
|
|
connectOpts._shouldRetryKeyboardInteractiveFirst = () => shouldRetryKeyboardInteractiveFirst;
|
|
}
|
|
}
|
|
|
|
// Handle chain/proxy connections
|
|
if (hasJumpHosts) {
|
|
// Pass fetched keys to chain connection to avoid re-reading files
|
|
options._defaultKeys = discoveredDefaultKeys;
|
|
options._sshDiagnosticLogger = log;
|
|
|
|
const chainResult = await connectThroughChain(
|
|
event,
|
|
options,
|
|
jumpHosts,
|
|
options.hostname,
|
|
options.port || 22,
|
|
sessionId
|
|
);
|
|
connectionSocket = chainResult.socket;
|
|
chainConnections = chainResult.connections;
|
|
|
|
connectOpts.sock = connectionSocket;
|
|
delete connectOpts.host;
|
|
delete connectOpts.port;
|
|
|
|
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
|
|
} else if (hasProxy) {
|
|
sendProgress(1, 1, options.hostname, 'connecting');
|
|
connectionSocket = await createProxySocket(
|
|
options.proxy,
|
|
options.hostname,
|
|
options.port || 22,
|
|
{ timeoutMs: tcpConnectTimeoutMs }
|
|
);
|
|
connectOpts.sock = connectionSocket;
|
|
delete connectOpts.host;
|
|
delete connectOpts.port;
|
|
} else {
|
|
// Direct connection (no jump hosts, no proxy)
|
|
sendProgress(1, 1, options.hostname, 'connecting');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
|
|
let settled = false;
|
|
const connectionStartedAt = Date.now();
|
|
let connectionStage = "connecting";
|
|
let authBanner = "";
|
|
let detachX11Forwarding = null;
|
|
// Reference-counted descriptor for this connection. Created when the
|
|
// shell channel opens; shared with any tabs that later reuse this
|
|
// connection (issue #1204). Tearing the transport down is funneled
|
|
// through releaseConnectionRef so the last channel — not whichever
|
|
// channel happens to close first — ends the connection + chain.
|
|
let connRef = null;
|
|
let authReadyTimer = null;
|
|
const clearAuthReadyTimer = () => {
|
|
if (authReadyTimer) {
|
|
clearTimeout(authReadyTimer);
|
|
authReadyTimer = null;
|
|
}
|
|
};
|
|
// Session-log stream token for THIS connection's owner channel,
|
|
// captured in the closure so the connection-level error/timeout/close
|
|
// handlers stop only this connection's stream. Reading it back off the
|
|
// session map would risk stopping a *newer* same-sessionId stream
|
|
// after a reconnect (the token guard from #916). Stays null until the
|
|
// owner shell opens.
|
|
let ownerLogStreamToken = null;
|
|
|
|
// End the shared transport directly when we fail *before* a session
|
|
// (and its connRef) exists; once connRef exists, teardown goes through
|
|
// releaseConnectionRef via the stream close handler instead.
|
|
const teardownTransport = () => {
|
|
if (connRef) return;
|
|
try { conn.end(); } catch { }
|
|
for (const c of chainConnections) {
|
|
try { c.end(); } catch { }
|
|
}
|
|
};
|
|
|
|
conn.once("connect", () => {
|
|
runWhenProxyConnectionReady(conn._sock, () => {
|
|
try { conn._sock?.setTimeout?.(0); } catch { }
|
|
clearAuthReadyTimer();
|
|
connectionStage = "tcp-connected";
|
|
authReadyTimer = setTimeout(() => conn.emit("timeout"), authReadyTimeoutMs);
|
|
authReadyTimer.unref?.();
|
|
log("target tcp connected", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
elapsedMs: Date.now() - connectionStartedAt,
|
|
authReadyTimeoutMs,
|
|
});
|
|
sendProgress(totalHops, totalHops, options.hostname, 'tcp-connected');
|
|
enableSshNoDelay(conn);
|
|
});
|
|
});
|
|
if (connectOpts.sock) enableTcpNoDelay(connectOpts.sock);
|
|
|
|
conn.once("handshake", () => {
|
|
connectionStage = "handshake";
|
|
console.log(`${logPrefix} ${options.hostname} handshake complete`);
|
|
log("target handshake complete", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
elapsedMs: Date.now() - connectionStartedAt,
|
|
});
|
|
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
|
|
});
|
|
|
|
conn.on("banner", (message) => {
|
|
authBanner = String(message || "").trim();
|
|
log("auth banner received", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
length: authBanner.length,
|
|
});
|
|
});
|
|
|
|
conn.once("ready", () => {
|
|
clearAuthReadyTimer();
|
|
connectionStage = "ready";
|
|
console.log(`${logPrefix} ${options.hostname} ready`);
|
|
log("target ready", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
elapsedMs: Date.now() - connectionStartedAt,
|
|
remoteSshVersion: (conn && typeof conn._remoteVer === 'string') ? conn._remoteVer : '',
|
|
});
|
|
|
|
// Cache the successful auth method
|
|
if (connectOpts._lastTriedMethodRef) {
|
|
const successMethod = connectOpts._lastTriedMethodRef();
|
|
if (successMethod) {
|
|
setCachedAuthMethod(connectOpts.username, options.hostname, options.port, successMethod);
|
|
}
|
|
}
|
|
|
|
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
|
|
sendProgress(totalHops, totalHops, options.hostname, 'shell');
|
|
|
|
let establishedOwnerSession = null;
|
|
const sendTerminalMessage = (data) => {
|
|
const current = sessions.get(sessionId);
|
|
if (establishedOwnerSession && current !== establishedOwnerSession) return;
|
|
emitTerminalSessionData(event.sender, sessionId, data, {
|
|
...(establishedOwnerSession ? { session: establishedOwnerSession } : {}),
|
|
cols: current?.cols,
|
|
rows: current?.rows,
|
|
});
|
|
};
|
|
|
|
const x11FakeCookie = options.x11Forwarding
|
|
? crypto.randomBytes(16).toString("hex")
|
|
: null;
|
|
|
|
if (options.x11Forwarding) {
|
|
detachX11Forwarding = attachX11Forwarding(conn, {
|
|
display: options.x11Display,
|
|
fakeCookie: x11FakeCookie,
|
|
sendMessage: sendTerminalMessage,
|
|
});
|
|
}
|
|
|
|
const shellOptions = {
|
|
env: {
|
|
COLORTERM: "truecolor",
|
|
...(options.env || {}),
|
|
},
|
|
};
|
|
|
|
if (options.x11Forwarding) {
|
|
shellOptions.x11 = {
|
|
protocol: "MIT-MAGIC-COOKIE-1",
|
|
cookie: x11FakeCookie,
|
|
screen: 0,
|
|
single: false,
|
|
};
|
|
}
|
|
|
|
openBoundedSshShellCallback(
|
|
conn,
|
|
{
|
|
term: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
},
|
|
shellOptions,
|
|
(err, stream) => {
|
|
if (err) {
|
|
log("shell open failed", { sessionId, hostname: options.hostname, error: err.message });
|
|
if (detachX11Forwarding) detachX11Forwarding();
|
|
settled = true;
|
|
conn.end();
|
|
for (const c of chainConnections) {
|
|
try { c.end(); } catch { }
|
|
}
|
|
if (options.x11Forwarding && /x11/i.test(err.message || "")) {
|
|
sendTerminalMessage("\r\n[X11] Could not enable X11 forwarding. Make sure X11 forwarding is allowed on the server and xauth is installed.\r\n");
|
|
}
|
|
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
|
|
|
// Create the shared reference-counted descriptor for this
|
|
// connection now that the owning channel is open, then wire the
|
|
// shell up through the shared helper.
|
|
let ownerSession;
|
|
try {
|
|
ownerSession = setupShellSession({
|
|
conn,
|
|
stream,
|
|
options: {
|
|
...options,
|
|
_actualAgentForwarding: Boolean(connectOpts.agentForward),
|
|
},
|
|
sessionId,
|
|
event,
|
|
log,
|
|
detachX11Forwarding,
|
|
chainConnections,
|
|
isReused: false,
|
|
});
|
|
} catch (setupErr) {
|
|
// Callback runs from openBoundedSshShellCallback's Promise
|
|
// .then without a catch — reject the owning start Promise.
|
|
if (detachX11Forwarding) {
|
|
try { detachX11Forwarding(); } catch { /* ignore */ }
|
|
}
|
|
settled = true;
|
|
reject(setupErr);
|
|
return;
|
|
}
|
|
establishedOwnerSession = ownerSession;
|
|
connRef = createConnectionRef(ownerSession, conn, chainConnections);
|
|
if (pendingDialCoordination) {
|
|
completeTransportDial(pendingDialCoordination, connRef);
|
|
}
|
|
// Capture this connection's log stream token in the closure so
|
|
// the connection-level handlers below stop the right stream even
|
|
// after a same-sessionId reconnect (#916).
|
|
ownerLogStreamToken = ownerSession._logStreamToken;
|
|
|
|
settled = true;
|
|
resolve({ sessionId });
|
|
}
|
|
);
|
|
});
|
|
|
|
conn.on("error", (err) => {
|
|
clearAuthReadyTimer();
|
|
// After the promise is settled, we can't reject again. But if the
|
|
// session was already established (resolved), we still need to notify
|
|
// the renderer about transport errors so the session shows as failed
|
|
// rather than silently closing.
|
|
// Don't send netcatty:exit here — the stream close handler will flush
|
|
// any buffered data first and then send exit with this error info.
|
|
if (settled) {
|
|
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
|
|
log("post-connect transport error", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: err.message,
|
|
code: err.code,
|
|
level: err.level,
|
|
});
|
|
// Store the error so the close handler can include it in the exit event.
|
|
// ssh2 closes every channel when the transport errors, so each
|
|
// affected session (the owner and any reused siblings) gets the
|
|
// flag and reports the error via its own stream close handler.
|
|
const currentSession = sessions.get(sessionId);
|
|
const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef);
|
|
if (ownsCurrentSession) {
|
|
currentSession._transportError = err.message;
|
|
}
|
|
if (connRef) {
|
|
for (const sibling of sessions.values()) {
|
|
if (sibling !== currentSession && sibling.connRef === connRef) {
|
|
sibling._transportError = err.message;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const contents = event.sender;
|
|
|
|
const isAuthError = isSshAuthFailure(err);
|
|
|
|
// Clear cached auth method on auth failure so next attempt tries all methods
|
|
if (isAuthError) {
|
|
if (
|
|
!options._skipPasswordMethod &&
|
|
connectOpts.password &&
|
|
connectOpts._shouldRetryKeyboardInteractiveFirst?.()
|
|
) {
|
|
err.retryKeyboardInteractiveFirst = true;
|
|
log("auth failure marked for KI-first retry", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
});
|
|
}
|
|
clearCachedAuthMethod(connectOpts.username, options.hostname, options.port);
|
|
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
|
|
log("authentication failed", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: err.message,
|
|
code: err.code,
|
|
level: err.level,
|
|
});
|
|
safeSend(contents, "netcatty:auth:failed", {
|
|
sessionId,
|
|
error: err.message,
|
|
hostname: options.hostname
|
|
});
|
|
} else {
|
|
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
|
log("connection error", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: err.message,
|
|
code: err.code,
|
|
level: err.level,
|
|
});
|
|
}
|
|
|
|
const visibleError = userVisibleSshErrorMessage(err, options);
|
|
sendProgress(totalHops, totalHops, options.hostname, 'error', visibleError);
|
|
const suppressPreShellAuthExit = Boolean(options._suppressPreShellAuthExit && isAuthError);
|
|
if (suppressPreShellAuthExit) {
|
|
log("suppressing pre-shell auth exit for wrapper-managed retry", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
});
|
|
} else {
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { sessionId, exitCode: 1, error: visibleError, reason: "error" });
|
|
}
|
|
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
|
|
if (detachX11Forwarding) {
|
|
detachX11Forwarding();
|
|
detachX11Forwarding = null;
|
|
}
|
|
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
|
closeTerminalOutputSession?.(sessionId);
|
|
sessions.delete(sessionId);
|
|
sessionEncodings.delete(sessionId);
|
|
sessionDecoders.delete(sessionId);
|
|
teardownTransport();
|
|
// Destroy the connection to prevent further socket errors from leaking
|
|
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
|
try { conn.destroy(); } catch { }
|
|
settled = true;
|
|
reject(err);
|
|
});
|
|
|
|
conn.once("timeout", () => {
|
|
clearAuthReadyTimer();
|
|
console.error(`${logPrefix} ${options.hostname} connection timeout`);
|
|
const err = new Error(`Connection timeout to ${options.hostname}`);
|
|
log("connection timeout", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
error: err.message,
|
|
stage: connectionStage,
|
|
elapsedMs: Date.now() - connectionStartedAt,
|
|
tcpConnectTimeoutMs,
|
|
authReadyTimeoutMs,
|
|
});
|
|
const contents = event.sender;
|
|
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
|
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
|
|
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
|
closeTerminalOutputSession?.(sessionId);
|
|
sessions.delete(sessionId);
|
|
sessionEncodings.delete(sessionId);
|
|
sessionDecoders.delete(sessionId);
|
|
teardownTransport();
|
|
try { conn.destroy(); } catch { }
|
|
settled = true;
|
|
reject(err);
|
|
});
|
|
|
|
conn.once("close", () => {
|
|
clearAuthReadyTimer();
|
|
const contents = event.sender;
|
|
const currentSession = sessions.get(sessionId);
|
|
const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef);
|
|
log("connection closed", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
settled,
|
|
staleForCurrentSession: Boolean(currentSession && !ownsCurrentSession),
|
|
transportError: ownsCurrentSession ? currentSession?._transportError : undefined,
|
|
});
|
|
if (!settled) {
|
|
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
|
|
}
|
|
// This handler owns teardown for the *owner* session only. ssh2
|
|
// fires conn "close" before the owner's stream "close" on a
|
|
// transport drop, so we clean the owner up here; the owner's stream
|
|
// close then no-ops because the session is already gone. Reused
|
|
// sibling channels each clean themselves up via their own stream
|
|
// "close" (ssh2 closes every channel when the transport drops).
|
|
if (ownsCurrentSession) {
|
|
const session = currentSession;
|
|
const transportError = session?._transportError;
|
|
if (transportError) {
|
|
// A transport error was recorded — report it as an error exit
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
|
sessionId,
|
|
exitCode: 1,
|
|
error: transportError,
|
|
reason: "error",
|
|
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
|
});
|
|
} else {
|
|
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
|
sessionId,
|
|
exitCode: 0,
|
|
reason: "closed",
|
|
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
|
});
|
|
}
|
|
// Use this connection's captured token so a late close from an
|
|
// old transport can't stop a newer same-sessionId stream (#916).
|
|
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
|
|
session?.zmodemSentry?.cancel();
|
|
// Release the owner's hold on the shared connection. The transport
|
|
// is already closing, but this decrements the reference count and,
|
|
// when it is the last holder, ends the jump-host chain. Reused
|
|
// siblings (if any) keep the count above zero until their own
|
|
// stream close handlers run.
|
|
releaseConnectionRef(session);
|
|
closeTerminalOutputSession?.(sessionId);
|
|
sessions.delete(sessionId);
|
|
sessionEncodings.delete(sessionId);
|
|
sessionDecoders.delete(sessionId);
|
|
} else {
|
|
// Owner already cleaned up (e.g. its stream closed first). Ensure
|
|
// this connection's log stream is stopped defensively, scoped by
|
|
// the captured token so a reconnect's fresh stream is left alone.
|
|
if (ownerLogStreamToken) {
|
|
sessionLogStreamManager.stopStream(sessionId, ownerLogStreamToken);
|
|
}
|
|
}
|
|
if (!settled) {
|
|
settled = true;
|
|
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
|
|
}
|
|
});
|
|
|
|
// Handle keyboard-interactive authentication (2FA/MFA). Uses the shared
|
|
// factory so PAM-wrapped single-password prompts get auto-filled from
|
|
// the saved host password (#969) — same path the chain/SFTP/port-
|
|
// forwarding bridges go through.
|
|
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
|
sender,
|
|
sessionId,
|
|
hostId: options.hostId,
|
|
hostname: options.hostname,
|
|
password: options.password,
|
|
logPrefix,
|
|
scope: "terminal",
|
|
bootEpoch: options.bootEpoch,
|
|
getAuthBanner: () => authBanner,
|
|
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
|
|
onAutoFill: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, 'auth-attempt', 'using saved password',
|
|
),
|
|
onPromptShown: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...',
|
|
),
|
|
onUserResponded: () => sendProgress(
|
|
totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded',
|
|
),
|
|
}));
|
|
|
|
|
|
// Enable keyboard-interactive authentication in authHandler
|
|
// Note: If authHandler is a function (for fallback support), keyboard-interactive
|
|
// is already included in the auth methods list
|
|
if (Array.isArray(connectOpts.authHandler)) {
|
|
// Add keyboard-interactive after the existing methods
|
|
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
|
connectOpts.authHandler.push("keyboard-interactive");
|
|
}
|
|
} else if (typeof connectOpts.authHandler !== "function") {
|
|
// Create authHandler with keyboard-interactive support
|
|
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
|
|
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
|
|
const authMethods = [];
|
|
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
|
|
if (loginAgent) authMethods.push("agent");
|
|
if (connectOpts.privateKey) authMethods.push("publickey");
|
|
if (connectOpts.password && !options._skipPasswordMethod) {
|
|
authMethods.push("password");
|
|
}
|
|
authMethods.push("keyboard-interactive");
|
|
const dedupedAuthMethods = Array.from(new Set(authMethods));
|
|
connectOpts.authHandler = createOrderedStringAuthHandler(
|
|
dedupedAuthMethods,
|
|
createAuthPhase(),
|
|
undefined,
|
|
loginAgent !== connectOpts.agent
|
|
? { username: connectOpts.username, agent: loginAgent }
|
|
: undefined,
|
|
);
|
|
log("Using simple array authHandler", {
|
|
authMethods: dedupedAuthMethods,
|
|
usedDefaultKeyAsPrimary,
|
|
});
|
|
}
|
|
// If authHandler is a function, it already handles keyboard-interactive
|
|
|
|
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
|
log("connect options prepared", {
|
|
sessionId,
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
hasSocket: !!connectOpts.sock,
|
|
hasProxy,
|
|
jumpHostCount: jumpHosts.length,
|
|
timeout: connectOpts.timeout,
|
|
readyTimeout: connectOpts.readyTimeout,
|
|
tryKeyboard: connectOpts.tryKeyboard,
|
|
hasPassword: !!connectOpts.password,
|
|
hasPrivateKey: !!connectOpts.privateKey,
|
|
hasAgent: !!connectOpts.agent,
|
|
authHandlerType: Array.isArray(connectOpts.authHandler) ? "array" : typeof connectOpts.authHandler,
|
|
authMethods: Array.isArray(connectOpts.authHandler)
|
|
? connectOpts.authHandler
|
|
: undefined,
|
|
});
|
|
conn.connect(connectOpts);
|
|
}).catch((err) => {
|
|
if (pendingDialCoordination && !options._deferPendingDialFailure) {
|
|
failTransportDial(pendingDialCoordination, err);
|
|
}
|
|
throw err;
|
|
});
|
|
} catch (err) {
|
|
if (pendingDialCoordination && !options._deferPendingDialFailure) {
|
|
failTransportDial(pendingDialCoordination, err);
|
|
}
|
|
console.error("[Chain] SSH chain connection error:", err.message);
|
|
const isAuthError = isSshAuthFailure(err);
|
|
const suppressPreShellAuthExit = Boolean(options._suppressPreShellAuthExit && isAuthError);
|
|
if (!suppressPreShellAuthExit) {
|
|
const contents = event.sender;
|
|
safeSendSessionExit(
|
|
{ safeSend, electronModule, sessions },
|
|
contents,
|
|
sessionId,
|
|
{ sessionId, exitCode: 1, error: userVisibleSshErrorMessage(err, options) },
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
return { startSSHSession };
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
SSH_AUTH_READY_TIMEOUT_MS,
|
|
SSH_TCP_CONNECT_TIMEOUT_MS,
|
|
createStartSessionApi,
|
|
resolveSshConnectionTimeouts,
|
|
shouldOfferAgentForLogin,
|
|
shouldPrepareSystemAgentForLogin,
|
|
resolveUnlockedEncryptedKeysForAuth,
|
|
shouldPromoteCachedAuthMethod,
|
|
applyAgentForwarding,
|
|
prepareAgentForwardingOptions,
|
|
};
|