Files

1575 lines
61 KiB
JavaScript
Raw Permalink Normal View History

/**
* SSH Bridge - Handles SSH connections, sessions, and related operations
* Extracted from main.cjs for single responsibility
*/
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const crypto = require("node:crypto");
const { exec } = require("node:child_process");
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const { createProxySocket, runWhenProxyConnectionReady } = require("./proxyUtils.cjs");
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
createOrderedStringAuthHandler,
createAuthPhase,
markAuthPhasePartialSuccess,
canRepeatKeyboardInteractive,
shouldSkipKiPasswordAutoFill,
applyAuthToConnOpts,
safeSend: authSafeSend,
requestPassphrasesForEncryptedKeys,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getSshAgentSocket,
getAvailableAgentSocket: getAvailableSystemAgentSocket,
getAvailableForwardingAgentSocket,
prepareSystemSshAgentForAuth,
readFileNoFollow,
expandIdentityFilePath,
isAutoFillablePasswordChallenge,
preparePrivateKeyForAuth,
loadIdentityFileForAuth,
loadFirstIdentityFileForAuth,
hasUserConfiguredKey,
isPasswordProvided,
PassphraseCancelledError,
isPassphraseCancelledError,
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { trackSessionIdlePrompt, looksLikeIdleAutoLogout } = require("./ai/shellUtils.cjs");
const { createZmodemSentry, waitForWritableDrain } = require("./zmodemHelper.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const {
buildAlgorithms,
_resetAlgorithmSupportCacheForTests,
} = require("./sshAlgorithms.cjs");
const { enableSshNoDelay, enableTcpNoDelay } = require("./tcpNoDelay.cjs");
const { createTcpConnectLatencyProbe } = require("./tcpConnectLatency.cjs");
const {
configureTerminalSessionDataEmitter,
} = require("./emitTerminalSessionData.cjs");
// Default SSH key names in priority order (preferred keys tried first)
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
// Match any private key file: id_* but not *.pub
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
const {
createStartSessionApi,
resolveSshConnectionTimeouts,
} = require("./sshBridge/startSession.cjs");
const { ensureMacLocalNetworkAccess, attachMacLocalNetworkProbeResult } = require("./macLocalNetworkAccess.cjs");
function quoteShellArg(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function hasUsableProxy(proxy) {
if (!proxy) return false;
if (proxy.type === "command") return !!proxy.command?.trim();
return !!(proxy.host && proxy.port);
}
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
*/
function looksLikePrivateKey(content) {
if (!content || typeof content !== "string") return false;
const trimmed = content.trimStart();
return trimmed.startsWith("-----BEGIN") ||
trimmed.startsWith("openssh-key-v1") ||
trimmed.startsWith("PuTTY-User-Key-File");
}
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
// Check for PuTTY PPK encrypted format
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
return true;
}
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Extract the base64 content between the markers
const base64Match = keyContent.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
// Scan ~/.ssh/ for all files matching id_* (same as Tabby/OpenSSH),
// with preferred key types tried first
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return null;
}
// Sort: preferred keys first (in order), then rest alphabetically
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
log("Searching for default SSH keys", { sshDir, found: sorted });
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) continue;
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
continue;
}
const encrypted = isKeyEncrypted(privateKey);
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
if (encrypted) {
log("Skipping encrypted default key", { keyPath, keyName: name });
continue;
}
log("Found default key", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} catch (e) {
log("Failed to read default key", { keyPath, error: e.message });
continue;
}
}
log("No suitable default SSH key found");
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* Returns all non-encrypted keys for fallback authentication
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string }>>}
*/
async function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return [];
}
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
const sorted = [...preferred, ...rest];
log("Searching for ALL default SSH keys", { sshDir, found: sorted });
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) return null;
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
return null;
}
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
log("Found default key for fallback", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
return null;
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
return null;
}
});
const results = await Promise.all(promises);
const keys = results.filter(Boolean);
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if an SSH agent is available on Windows by connecting to the
* well-known named pipe. fs.statSync is unreliable for named pipes (returns
* EBUSY even when usable), so we use net.connect() as the authoritative check.
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
*/
function checkWindowsSshAgent() {
if (process.platform !== "win32") {
return Promise.resolve({ running: true, startupType: null, error: null });
}
const net = require("net");
return new Promise((resolve) => {
const socket = net.connect(WIN_SSH_AGENT_PIPE);
let settled = false;
const finish = (ok, error) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve({
running: ok,
startupType: ok ? "running" : "stopped",
error: ok ? null : (error || "SSH Agent pipe not connectable"),
});
};
socket.setTimeout(1000);
socket.once("connect", () => finish(true, null));
socket.once("timeout", () => finish(false, "SSH Agent pipe connect timeout"));
socket.once("error", (err) => finish(false, err.message));
});
}
async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check", agentStatus);
return agentStatus.running ? WIN_SSH_AGENT_PIPE : null;
}
return getSshAgentSocket();
}
const SSH_DEBUG_REDACTED_KEYS = new Set([
"password",
"passphrase",
"privateKey",
"key",
"certificate",
]);
let sshDebugLoggingEnabled = process.env.NETCATTY_SSH_DEBUG === "1";
let sshDebugLogFilePath = tempDirBridge.getTempFilePath("netcatty-ssh.log");
function sanitizeSshDebugValue(value, key = "") {
if (SSH_DEBUG_REDACTED_KEYS.has(key)) return "[redacted]";
if (Array.isArray(value)) return value.map((item) => sanitizeSshDebugValue(item));
if (value && typeof value === "object") {
const result = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
result[entryKey] = sanitizeSshDebugValue(entryValue, entryKey);
}
return result;
}
return value;
}
function setSshDebugLoggingEnabled(enabled, options = {}) {
sshDebugLoggingEnabled = !!enabled;
if (typeof options.logFilePath === "string" && options.logFilePath.trim()) {
sshDebugLogFilePath = options.logFilePath;
}
}
function isSshDebugLoggingEnabled() {
return sshDebugLoggingEnabled;
}
function getSshDebugLogFilePath() {
return sshDebugLogFilePath;
}
function appendSshDiagnosticLog(msg, data, options = {}) {
const enabled = Object.prototype.hasOwnProperty.call(options, "enabled")
? !!options.enabled
: isSshDebugLoggingEnabled();
if (!enabled) return;
const safeData = data ? sanitizeSshDebugValue(data) : undefined;
const line = `[${new Date().toISOString()}] ${msg} ${safeData ? JSON.stringify(safeData) : ""}\n`;
try { fs.appendFileSync(sshDebugLogFilePath, line); } catch { }
console.log("[SSH]", msg, safeData ? JSON.stringify(safeData, null, 2) : "");
}
const log = appendSshDiagnosticLog;
log.isEnabled = isSshDebugLoggingEnabled;
function createSshDiagnosticLogger(enabled) {
const isEnabled = !!enabled;
const logger = (msg, data) => appendSshDiagnosticLog(msg, data, { enabled: isEnabled });
logger.isEnabled = () => isEnabled;
return logger;
}
function shouldLogSshDebugMessage(msg) {
if (typeof msg !== "string") return false;
return /auth|publickey|keyboard|handshake|kex|newkeys|dh gex/i.test(msg);
}
function attachSshDebugLogger(connectOpts, logger = log) {
if (typeof logger.isEnabled === "function" && !logger.isEnabled()) return;
connectOpts.debug = (msg) => {
if (shouldLogSshDebugMessage(msg)) {
logger("ssh2 debug", { msg });
}
};
}
function logSshAlgorithms(label, algorithms, extra = {}, logger = log) {
logger(`${label} algorithm configuration`, {
...extra,
kex: algorithms.kex,
cipher: algorithms.cipher,
hmac: algorithms.hmac,
serverHostKey: algorithms.serverHostKey,
});
}
async function getSshDebugLogInfo() {
let size = 0;
let exists = false;
try {
const stat = await fs.promises.stat(sshDebugLogFilePath);
exists = stat.isFile();
size = stat.size;
} catch {
exists = false;
}
return {
enabled: isSshDebugLoggingEnabled(),
path: sshDebugLogFilePath,
exists,
size,
};
}
async function openSshDebugLogDir() {
if (!electronModule?.shell?.openPath) return { success: false, error: "shell unavailable" };
try {
await fs.promises.mkdir(path.dirname(sshDebugLogFilePath), { recursive: true });
const error = await electronModule.shell.openPath(path.dirname(sshDebugLogFilePath));
return error ? { success: false, error } : { success: true };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
let terminalOutputChannel = null;
let selectZmodemUploadFiles = null;
let selectZmodemDownloadDirectory = null;
// Authentication method cache - remembers successful auth methods per host
// Key format: "username@hostname:port"
// Value: { method: "password" | "publickey" | "publickey-default" }
// Cache persists until auth failure, then cleared to retry all methods
const { createSshAuthMethodCache } = require("./sshAuthMethodCache.cjs");
const authMethodCache = createSshAuthMethodCache();
// Per-session terminal encoding (default: utf-8)
const sessionEncodings = new Map();
// Per-session stateful iconv decoders (keyed by sessionId, value: { stdout, stderr })
const sessionDecoders = new Map();
const iconv = require("iconv-lite");
const { encodeTerminalInput, normalizeTerminalEncoding } = require("./terminalEncoding.cjs");
function getSessionDecoder(sessionId, stream) {
let decoders = sessionDecoders.get(sessionId);
if (!decoders) {
decoders = { stdout: null, stderr: null };
sessionDecoders.set(sessionId, decoders);
}
if (!decoders[stream]) {
const enc = sessionEncodings.get(sessionId) || "utf-8";
decoders[stream] = iconv.getDecoder(enc);
}
return decoders[stream];
}
function resetSessionDecoders(sessionId) {
const enc = sessionEncodings.get(sessionId) || "utf-8";
const decoders = { stdout: iconv.getDecoder(enc), stderr: iconv.getDecoder(enc) };
sessionDecoders.set(sessionId, decoders);
}
function getAuthCacheKey(username, hostname, port) {
return `${username}@${hostname}:${port || 22}`;
}
function getCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
const cached = authMethodCache.get(key);
if (cached) {
log("Using cached auth method", { key, method: cached.method });
return cached.method;
}
return null;
}
function setCachedAuthMethod(username, hostname, port, method) {
const key = getAuthCacheKey(username, hostname, port);
log("Caching successful auth method", { key, method });
authMethodCache.set(key, { method });
}
function clearCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
log("Clearing cached auth method", { key });
authMethodCache.delete(key);
}
const { safeSend } = require("./ipcUtils.cjs");
const { openBoundedForwardOutCallback } = require("./boundedSshChannelOpen.cjs");
const {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
transferConnectionRef,
consumePendingShellReconnectRisk,
markEndpointNoIdlePark,
findReusableSession,
findTransportByEndpoint,
resolveTransportForReuse,
beginTransportDial,
waitForTransportDial,
completeTransportDial,
failTransportDial,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
normalizeEndpoint,
discardAllTransports,
} = require("./sshConnectionPool.cjs");
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
/**
* Initialize the SSH bridge with dependencies
*/
function init(deps) {
sessions = deps.sessions;
electronModule = deps.electronModule;
terminalOutputChannel = deps.terminalOutputChannel || null;
selectZmodemUploadFiles = deps.selectZmodemUploadFiles || null;
selectZmodemDownloadDirectory = deps.selectZmodemDownloadDirectory || null;
configureTerminalSessionDataEmitter({
getSession: (sessionId) => sessions?.get(sessionId),
outputChannel: terminalOutputChannel,
onSessionActivity: deps.reportOpenedSessionActivity,
});
}
function openTerminalOutputSession(sessionId, webContents) {
terminalOutputChannel?.openSession?.(sessionId, webContents);
}
function closeTerminalOutputSession(sessionId) {
terminalOutputChannel?.closeSession?.(sessionId);
}
/**
* Connect through a chain of jump hosts
*/
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
const targetConnectionTimeouts = resolveSshConnectionTimeouts(options);
const sender = event.sender;
const connections = options?._connectionsRef || [];
const sshDiagnosticLogger = options?._sshDiagnosticLogger || log;
const keyboardInteractiveScope = options?._keyboardInteractiveScope || "terminal";
let currentSocket = null;
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
try {
const totalHops = jumpHosts.length;
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
const conn = new SSHClient();
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = conn;
options._tunnelRef.chainConnections = connections;
}
// Per-hop keepalive. Each jump entry already carries its own resolved
// interval/countMax (see resolveHostKeepalive in domain/host.ts), so
// a chain with a router as the bastion and a cloud host at the end
// can have keepalive=0 on the bastion and the cloud-friendly values
// on the final target — without one stepping on the other. We fall
// back to the target-call options for backward compat with older
// serializers that don't populate the per-hop fields yet.
const hopInterval = jump.keepaliveInterval ?? options.keepaliveInterval ?? 0;
const hopCountMax = jump.keepaliveCountMax ?? options.keepaliveCountMax ?? 10;
const hopConnectionTimeouts = resolveSshConnectionTimeouts(jump);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
timeout: hopConnectionTimeouts.tcpConnectTimeoutMs,
// ssh2 starts readyTimeout before TCP connects. The auth-ready timer
// below starts explicitly from the connection event instead.
readyTimeout: 0,
keepaliveInterval: hopInterval > 0 ? hopInterval * 1000 : 0,
keepaliveCountMax: hopInterval > 0 ? hopCountMax : 0,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
// Per-hop algorithm settings. Two distinct semantics:
//
// - `legacyAlgorithms` is *append-only* — it widens the offered
// list — so falling back to the target's setting when the hop
// didn't override is safe and matches the historic chain-wide
// behavior (a user with a single old leaf only needs to flip
// the toggle on the leaf).
//
// - `skipEcdsaHostKey` and `algorithmOverrides` *narrow* the
// offered list (the first removes every `ecdsa-sha2-*`, the
// second replaces a category outright). Propagating those to
// a bastion that doesn't need them can lock the hop to
// algorithms it doesn't accept — e.g. an Ed25519-only bastion
// would still negotiate while ECDSA was offered, but breaks
// when the leaf's ECDSA skip is applied to it. Treat both as
// strictly per-host: an unset hop value uses the hop's default
// offer, not the leaf's narrower setting.
algorithms: buildAlgorithms(
jump.legacyAlgorithms ?? options.legacyAlgorithms,
{
skipEcdsaHostKey: jump.skipEcdsaHostKey,
algorithmOverrides: jump.algorithmOverrides,
},
),
};
connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId,
hostname: jump.hostname,
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
bootEpoch: options.bootEpoch,
});
attachSshDebugLogger(connOpts, sshDiagnosticLogger);
logSshAlgorithms("Jump host", connOpts.algorithms, {
hostname: jump.hostname,
port: jump.port || 22,
legacyAlgorithms: !!(jump.legacyAlgorithms ?? options.legacyAlgorithms),
skipEcdsaHostKey: !!jump.skipEcdsaHostKey,
hasAlgorithmOverrides: !!jump.algorithmOverrides,
}, sshDiagnosticLogger);
// Auth - support agent (certificate), key, password, and default key fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
const systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(jump, `[Chain] Hop ${i + 1}:`);
const identityFile = !jump.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
identityFilePaths: jump.identityFilePaths,
hostname: hopLabel,
initialPassphrase: jump.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
logPrefix: `[Chain] Hop ${i + 1}:`,
onPassphrasePromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "waiting for user input...",
),
onPassphrasePromptResolved: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "user responded",
),
onLoaded: (loaded) => {
if (loaded.passphrase) {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
}
console.log(`[Chain] Hop ${i + 1}: loaded identity file ${loaded.keyPath}`);
},
onError: (err, keyPath) => {
console.warn(`[Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
},
})
: null;
const inlineKey = jump.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey: jump.privateKey,
keyId: jump.keyId,
keyName: jump.keyId || jump.label || jump.username,
hostname: hopLabel,
initialPassphrase: jump.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
logPrefix: `[Chain] Hop ${i + 1}:`,
onPassphrasePromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "waiting for user input...",
),
onPassphrasePromptResolved: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "user responded",
),
})
: null;
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
let authAgent = null;
if (systemAuthAgent) {
connOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: jump.keyId || jump.username || "",
certificate: jump.certificate,
privateKey: effectivePrivateKey,
passphrase: effectivePassphrase,
},
});
connOpts.agent = authAgent;
} else if (effectivePrivateKey) {
connOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connOpts.passphrase = effectivePassphrase;
} else if (isKeyEncrypted(effectivePrivateKey)) {
// Key is encrypted but no passphrase provided — prompt the user
console.log(`[Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
const keyLabel = jump.label || hopLabel;
const result = await passphraseHandler.requestPassphrase(
sender,
`SSH key for ${keyLabel}`,
keyLabel,
hopLabel,
false,
{
signal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
}
);
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
if (result?.passphrase) {
connOpts.passphrase = result.passphrase;
} else {
// No passphrase (cancelled/skipped/timeout) — remove the encrypted
// key so buildAuthHandler won't try it and stall auth.
delete connOpts.privateKey;
if (result?.cancelled) {
throw new PassphraseCancelledError(hopLabel);
}
}
}
}
if (jump.password) connOpts.password = jump.password;
// Get default keys (either from options if pre-fetched, or fetch them now)
const defaultKeys = systemAuthAgent && jump.identitiesOnly
? []
: options._defaultKeys || await findAllDefaultPrivateKeys();
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const fallbackAgentSocket = jump.useSshAgent === false
? null
: jump.useSshAgent === true
? undefined
: await getAvailableAgentSocket();
const authConfig = buildAuthHandler({
authMethod: jump.authMethod,
requiresMfa: !!jump.requiresMfa,
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: systemAuthAgent && jump.identitiesOnly
? []
: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: fallbackAgentSocket,
allowAgentFallback: jump.useSshAgent !== false,
onAuthAttempt: (method) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
const hopAuthPhase = authConfig.authPhase || createAuthPhase();
// If first hop and proxy is configured, connect through proxy
const hasUsableJumpProxy = hasUsableProxy(jump.proxy);
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
if (effectiveHopProxy) {
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
timeoutMs: hopConnectionTimeouts.tcpConnectTimeoutMs,
onSocket: (socket) => {
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = socket;
options._tunnelRef.chainConnections = connections;
}
},
});
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
} else if (!isFirst && currentSocket) {
// Tunnel through previous hop
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
let settled = false;
let authReadyTimer = null;
let authBanner = "";
const clearAuthReadyTimer = () => {
if (authReadyTimer) {
clearTimeout(authReadyTimer);
authReadyTimer = null;
}
};
conn.once('handshake', () => {
if (settled) return;
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} handshake complete`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'authenticating');
});
conn.once('ready', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
resolve();
});
conn.once('error', (err) => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', err.message);
if (isChainAuthError(err)) {
err.isJumpHostAuthError = true;
err.jumpHostIndex = i;
err.jumpHostLabel = hopLabel;
err.jumpHostHostname = jump.hostname;
err.message = `Jump host authentication failed for "${hopLabel}": ${err.message}`;
}
reject(err);
});
conn.once('timeout', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
const errMsg = `Connection timeout to ${hopLabel}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
try { conn.destroy(); } catch { }
reject(new Error(errMsg));
});
conn.once('close', () => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
const errMsg = `Connection to ${hopLabel} closed before authentication completed`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
reject(new Error(errMsg));
});
conn.on('banner', (message) => {
authBanner = String(message || "").trim();
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId,
hostId: jump.hostId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
scope: keyboardInteractiveScope,
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
onAutoFill: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'using saved password',
),
onPromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...',
),
onUserResponded: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded',
),
}));
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.once('connect', () => {
runWhenProxyConnectionReady(conn._sock, () => {
try { conn._sock?.setTimeout?.(0); } catch { }
clearAuthReadyTimer();
authReadyTimer = setTimeout(
() => conn.emit('timeout'),
hopConnectionTimeouts.authReadyTimeoutMs,
);
authReadyTimer.unref?.();
sendProgress(i + 1, totalHops + 1, hopLabel, 'tcp-connected');
enableSshNoDelay(conn);
});
});
if (connOpts.sock) enableTcpNoDelay(connOpts.sock);
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort, nextConnectionTimeouts;
if (isLast) {
// Last jump host, forward to final target
nextHost = targetHost;
nextPort = targetPort;
nextConnectionTimeouts = targetConnectionTimeouts;
} else {
// Forward to next jump host
const nextJump = jumpHosts[i + 1];
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
nextConnectionTimeouts = resolveSshConnectionTimeouts(nextJump);
}
// Create forward stream to next hop
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
currentSocket = await new Promise((resolve, reject) => {
const forwardTimeoutMs = options?._forwardTimeoutMs
|| nextConnectionTimeouts.tcpConnectTimeoutMs;
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
const errMsg = `Connection timeout from ${hopLabel} to ${nextHost}:${nextPort}`;
console.error(`[Chain] Hop ${i + 1}/${totalHops}: forwardOut from ${hopLabel} to ${nextHost}:${nextPort} TIMEOUT`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
try { conn.destroy(); } catch { }
reject(new Error(errMsg));
}, forwardTimeoutMs);
if (!options?._forwardTimeoutMs) timeout.unref?.();
openBoundedForwardOutCallback(conn, '127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
if (settled) {
try { stream?.destroy?.(); } catch { }
return;
}
settled = true;
clearTimeout(timeout);
if (err) {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: forwardOut from ${hopLabel} to ${nextHost}:${nextPort} FAILED:`, err.message);
reject(err);
return;
}
console.log(`[Chain] Hop ${i + 1}/${totalHops}: forwardOut from ${hopLabel} to ${nextHost}:${nextPort} SUCCESS`);
resolve(stream);
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
connections,
sendProgress
};
} catch (err) {
if (options?._tunnelRef) {
options._tunnelRef.pendingConn = null;
options._tunnelRef.chainConnections = connections;
}
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch { }
}
throw err;
}
}
/**
* Start an SSH session
*/
const startSessionApi = createStartSessionApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
SSHClient, sshUtils, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler, hostKeyVerifier,
quoteShellArg,
fs, path, os, net, crypto, Buffer, process, console, setTimeout, clearTimeout,
createProxySocket, attachX11Forwarding, createPtyOutputBuffer, sessionLogStreamManager,
trackSessionIdlePrompt, looksLikeIdleAutoLogout, createZmodemSentry, waitForWritableDrain, enableSshNoDelay, enableTcpNoDelay,
iconv, getSessionDecoder, resetSessionDecoders, sessionEncodings, sessionDecoders, encodeTerminalInput,
normalizeTerminalEncoding,
connectThroughChain, getAvailableAgentSocket, getAvailableForwardingAgentSocket, getCachedAuthMethod, setCachedAuthMethod, clearCachedAuthMethod,
attachSshDebugLogger, logSshAlgorithms, safeSend, zmodemOverwritePending,
shouldLogSshDebugMessage, log, createSshDiagnosticLogger,
buildAlgorithms, randomUUID, findDefaultPrivateKey, findAllDefaultPrivateKeys,
openTerminalOutputSession, closeTerminalOutputSession,
get selectZmodemUploadFiles() { return selectZmodemUploadFiles; },
get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; },
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, prepareSystemSshAgentForAuth, hasUserConfiguredKey, isPasswordProvided, createKeyboardInteractiveHandler, createOrderedStringAuthHandler, createAuthPhase, markAuthPhasePartialSuccess, canRepeatKeyboardInteractive, shouldSkipKiPasswordAutoFill,
createConnectionRef, acquireConnectionRef, releaseConnectionRef, transferConnectionRef,
consumePendingShellReconnectRisk, markEndpointNoIdlePark,
findReusableSession, findTransportByEndpoint, resolveTransportForReuse, discardAllTransports,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
normalizeEndpoint,
get probeReceiveConflicts() { return probeReceiveConflicts; },
get removeRemoteFiles() { return removeRemoteFiles; },
get restoreRemoteModes() { return restoreRemoteModes; },
});
const { startSSHSession } = startSessionApi;
const { createExecCommandApi } = require("./sshBridge/execCommand.cjs");
const execCommandApi = createExecCommandApi({
SSHClient, NetcattyAgent, randomUUID, console, setTimeout, clearTimeout, Error,
findAllDefaultPrivateKeysFromHelper, preparePrivateKeyForAuth, loadIdentityFileForAuth,
prepareSystemSshAgentForAuth, getAvailableAgentSocket,
isPassphraseCancelledError, buildAlgorithms, buildAuthHandler, applyAuthToConnOpts,
createKeyboardInteractiveHandler, shouldSkipKiPasswordAutoFill, resolveSshConnectionTimeouts,
});
const { execCommand } = execCommandApi;
/**
* Generate SSH key pair
*/
async function generateKeyPair(event, options) {
const { type, bits, comment } = options;
try {
let keyType;
let keyBits = bits;
switch (type) {
case 'ED25519':
keyType = 'ed25519';
keyBits = undefined;
break;
case 'ECDSA':
keyType = 'ecdsa';
keyBits = bits || 256;
break;
case 'RSA':
default:
keyType = 'rsa';
keyBits = bits || 4096;
break;
}
const result = sshUtils.generateKeyPairSync(keyType, {
bits: keyBits,
comment: comment || 'netcatty-generated-key',
});
const privateKey = result.private;
const publicKey = result.public;
return {
success: true,
privateKey,
publicKey,
};
} catch (err) {
console.error('Key generation failed:', err);
return {
success: false,
error: err.message || 'Key generation failed',
};
}
}
/**
* Wrapper for SSH session handler to suppress noisy auth error stack traces
* Auth failures are expected when fallback to password is available
*/
function isAuthFailureMessage(message) {
const normalized = message?.toLowerCase() || '';
return normalized.includes('all configured authentication methods failed') ||
normalized.includes('authentication failed') ||
normalized.includes('too many authentication failures') ||
/permission denied\s*\(/.test(normalized) ||
normalized.includes('no authentication methods available');
}
function isStartAuthError(err) {
return err?.level === 'client-authentication' ||
isAuthFailureMessage(err?.message);
}
function isChainAuthError(err) {
return err?.level === 'client-authentication' ||
isAuthFailureMessage(err?.message);
}
function canHopUseEncryptedDefaultKeys(hop) {
if (hop.authMethod === "password" || hop.authMethod === "key" || hop.authMethod === "certificate") {
return false;
}
return !(hop.useSshAgent === true && hop.identitiesOnly === true);
}
function canRetryWithEncryptedDefaultKeys(options) {
if (options._unlockedEncryptedKeys?.length) return false;
return canHopUseEncryptedDefaultKeys(options) || (options.jumpHosts || []).some(canHopUseEncryptedDefaultKeys);
}
function resolveFailedAuthHop(options, err) {
if (!err?.isJumpHostAuthError) return options;
const jumpHosts = options.jumpHosts || [];
if (Number.isInteger(err.jumpHostIndex) && err.jumpHostIndex >= 0) {
return jumpHosts[err.jumpHostIndex] || null;
}
const matchingJumps = jumpHosts.filter((jump) => (
(err.jumpHostHostname && jump.hostname === err.jumpHostHostname)
|| (err.jumpHostLabel && jump.label === err.jumpHostLabel)
));
return matchingJumps.length === 1 ? matchingJumps[0] : null;
}
function canFailedHopRetryWithEncryptedDefaultKeys(options, err) {
const failedHop = resolveFailedAuthHop(options, err);
return Boolean(failedHop && canHopUseEncryptedDefaultKeys(failedHop));
}
function isStrictAgentAuthFailure(options, err) {
if (!err?.isJumpHostAuthError) {
return options.useSshAgent === true && options.identitiesOnly === true;
}
const jumpHosts = options.jumpHosts || [];
if (Number.isInteger(err.jumpHostIndex) && err.jumpHostIndex >= 0) {
const failedJump = jumpHosts[err.jumpHostIndex];
return failedJump?.useSshAgent === true && failedJump.identitiesOnly === true;
}
const matchingJumps = jumpHosts.filter((jump) => (
(err.jumpHostHostname && jump.hostname === err.jumpHostHostname)
|| (err.jumpHostLabel && jump.label === err.jumpHostLabel)
));
const failedJump = matchingJumps.length === 1 ? matchingJumps[0] : undefined;
return failedJump?.useSshAgent === true && failedJump.identitiesOnly === true;
}
function canReuseExistingSession(options) {
if (options.reuseTransport === false || !options.sourceSessionId || options.x11Forwarding) return false;
return Boolean(findReusableSession(sessions, options.sourceSessionId, {
hostname: options.hostname,
port: options.port || 22,
username: options.username || "root",
}));
}
function sendFinalStartFailureExit(event, options, err) {
const sessionId = options.sessionId;
if (!sessionId || event.sender?.isDestroyed?.()) return;
safeSend(event.sender, "netcatty:exit", {
sessionId,
exitCode: 1,
error: err?.message || String(err),
reason: "error",
});
}
async function startSSHSessionWithRetries(event, options, pendingDialState) {
let retryableEncryptedKeys = [];
let loadedRetryableEncryptedKeys = false;
let shouldSuppressInitialAuthExit = false;
const canRetryEncryptedDefaults = canRetryWithEncryptedDefaultKeys(options);
const canRetryKeyboardInteractiveFirst = Boolean(options.password && !options._skipPasswordMethod);
const mayReuseExistingSession = canRetryEncryptedDefaults && canReuseExistingSession(options);
const loadRetryableEncryptedKeys = async () => {
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
retryableEncryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
loadedRetryableEncryptedKeys = true;
return retryableEncryptedKeys;
};
if (canRetryEncryptedDefaults && !mayReuseExistingSession) {
await loadRetryableEncryptedKeys();
shouldSuppressInitialAuthExit = retryableEncryptedKeys.length > 0 || canRetryKeyboardInteractiveFirst;
} else if (mayReuseExistingSession) {
// Let Copy Tab reuse an authenticated transport without waiting on key
// discovery. If reuse falls back to a fresh connection and auth fails, the
// catch path below lazily loads encrypted keys before deciding final failure.
shouldSuppressInitialAuthExit = true;
} else if (canRetryKeyboardInteractiveFirst) {
shouldSuppressInitialAuthExit = true;
}
try {
return await startSSHSession(event, {
...options,
_suppressPreShellAuthExit: shouldSuppressInitialAuthExit,
_pendingDialState: pendingDialState,
_deferPendingDialFailure: true,
});
} catch (err) {
const isAuthError = isStartAuthError(err);
if (isAuthError) {
let authErrorSource = err;
if (canRetryKeyboardInteractiveFirst && err.retryKeyboardInteractiveFirst) {
console.log('[SSH] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...');
try {
return await startSSHSession(event, {
...options,
_skipPasswordMethod: true,
_suppressPreShellAuthExit: shouldSuppressInitialAuthExit,
_pendingDialState: pendingDialState,
_deferPendingDialFailure: true,
});
} catch (retryErr) {
const isRetryAuthError = isStartAuthError(retryErr);
if (isRetryAuthError) {
authErrorSource = retryErr;
console.log('[SSH] Keyboard-interactive-first retry failed authentication; checking remaining auth fallbacks...');
} else {
const connError = new Error(retryErr.message);
connError.level = retryErr.level || 'client-socket';
connError.code = retryErr.code;
throw connError;
}
}
}
// Check if there are encrypted default keys we haven't tried yet
// Only offer retry if no unlocked keys were provided in this attempt
if (
canRetryEncryptedDefaults
&& canFailedHopRetryWithEncryptedDefaultKeys(options, authErrorSource)
&& !isStrictAgentAuthFailure(options, authErrorSource)
) {
const encryptedKeys = loadedRetryableEncryptedKeys
? retryableEncryptedKeys
: await loadRetryableEncryptedKeys();
if (encryptedKeys.length > 0) {
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname,
{
signal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
},
);
// If user cancelled, don't retry even if some keys were unlocked
if (passphraseResult.cancelled) {
console.log('[SSH] User cancelled passphrase flow, not retrying');
} else if (passphraseResult.keys.length > 0) {
console.log('[SSH] User unlocked keys, retrying connection...', {
count: passphraseResult.keys.length,
keyNames: passphraseResult.keys.map(k => k.keyName)
});
// Retry connection with unlocked keys
// Wrap in try-catch to ensure consistent error handling for retry failures
try {
return await startSSHSession(event, {
...options,
_unlockedEncryptedKeys: passphraseResult.keys,
_pendingDialState: pendingDialState,
_deferPendingDialFailure: true,
});
} catch (retryErr) {
// Only purge cached passphrases if the error is specifically
// a passphrase/key-parsing failure, not a generic auth rejection.
const retryMsg = (retryErr.message || '').toLowerCase();
const isPassphraseError = retryMsg.includes('bad passphrase') ||
retryMsg.includes('integrity check failed') ||
retryMsg.includes('cannot parse privatekey');
if (isPassphraseError) {
try {
const failedKeyPaths = passphraseResult.keys.map(k => k.keyPath);
event.sender.send('netcatty:passphrase-auth-failed', { keyPaths: failedKeyPaths });
} catch (_) { /* sender may be destroyed */ }
}
// Re-wrap retry errors the same way as initial errors
const isRetryAuthError = isStartAuthError(retryErr);
if (isRetryAuthError) {
const authError = new Error(retryErr.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
if (retryErr.isJumpHostAuthError) {
authError.isJumpHostAuthError = true;
authError.jumpHostIndex = retryErr.jumpHostIndex;
authError.jumpHostLabel = retryErr.jumpHostLabel;
authError.jumpHostHostname = retryErr.jumpHostHostname;
}
throw authError;
}
// Wrap non-auth retry errors as connection errors to prevent crash
const connError = new Error(retryErr.message);
connError.level = retryErr.level || 'client-socket';
connError.code = retryErr.code;
throw connError;
}
} else {
console.log('[SSH] User did not unlock any keys, not retrying');
}
}
}
if (shouldSuppressInitialAuthExit) {
sendFinalStartFailureExit(event, options, authErrorSource);
}
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
const authError = new Error(authErrorSource.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
if (authErrorSource.isJumpHostAuthError) {
authError.isJumpHostAuthError = true;
authError.jumpHostIndex = authErrorSource.jumpHostIndex;
authError.jumpHostLabel = authErrorSource.jumpHostLabel;
authError.jumpHostHostname = authErrorSource.jumpHostHostname;
}
throw authError;
}
// Non-auth errors (e.g. ECONNRESET, ETIMEDOUT) — wrap in a clean Error
// so Electron's ipcMain.handle can serialize it back to the renderer
// instead of it becoming an uncaught exception that crashes the app.
// See: https://github.com/nicely-gg/netcatty/issues/482
const connError = new Error(err.message);
connError.level = err.level || 'client-socket';
connError.code = err.code;
throw connError;
}
}
async function startSSHSessionWrapper(event, options) {
const pendingDialState = { coordination: null };
let sourcePinHolder = null;
let sourceReuseState = options.sourceSessionId && options.reuseTransport !== false
? { attempted: false, session: null }
: null;
const sessionId = options.sessionId || require("node:crypto").randomUUID();
const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs");
const passphraseAbortController = registerPendingBootAbort(sessionId, options.bootEpoch);
if (options.sourceSessionId && options.reuseTransport !== false) {
const sourceAtRequest = findReusableSession(sessions, options.sourceSessionId);
if (sourceAtRequest?.connRef) {
sourcePinHolder = {};
acquireConnectionRef(sourcePinHolder, sourceAtRequest.connRef);
sourceReuseState.session = {
conn: sourceAtRequest.conn,
connRef: sourceAtRequest.connRef,
stream: sourceAtRequest.stream,
_reuseEndpoint: sourceAtRequest._reuseEndpoint,
};
}
}
try {
// Main-process UDP Local Network probe (TN3179 discard-port connect) so
// TCC attributes to Netcatty before the (possibly worker-hosted) SSH dial.
// See #2663 / #2673. Carry the resolved first-hop address so direct-start
// annotation (no terminal worker) still sees split-DNS LAN evidence.
const probeResult = await ensureMacLocalNetworkAccess(options);
return await startSSHSessionWithRetries(event, {
...attachMacLocalNetworkProbeResult(options, probeResult),
sessionId,
_passphraseSignal: passphraseAbortController.signal,
...(sourceReuseState ? { _sourceReuseState: sourceReuseState } : {}),
}, pendingDialState);
} catch (err) {
if (pendingDialState.coordination) {
failTransportDial(pendingDialState.coordination, err);
}
throw err;
} finally {
clearPendingBootAbort(sessionId, passphraseAbortController);
if (sourcePinHolder) releaseConnectionRef(sourcePinHolder);
}
}
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
*/
/**
* Return metadata about an already-connected session that was captured at
* connect time. Currently exposes the SSH server identification string
* (the `software` portion of the SSH-2.0 banner) so the renderer can
* classify network devices from the banner without running any additional
* exec channels.
*/
// Companion stats connection for Mosh sessions (issue #1198). Mosh runs over
// UDP and has no ssh2 connection, so getServerStats cannot open an exec
// channel. This helper lazily establishes a best-effort, non-interactive
// SSH connection reusing the handshake credentials and assigns it to
// session.conn so the existing stats path works unchanged.
const { createSystemKnownHostsApi } = require("./sshBridge/systemKnownHosts.cjs");
// Lets the Mosh stats companion trust a host whose key is already recorded in
// the user's system OpenSSH known_hosts (the trust source the Mosh handshake's
// system `ssh` actually uses), in addition to Netcatty's in-app vault.
const { isHostKeyTrustedBySystem } = createSystemKnownHostsApi({
fs, path, os, crypto, log,
});
const { createMoshStatsConnectionApi } = require("./sshBridge/moshStatsConnection.cjs");
const { ensureMoshStatsConnection, ensureEtStatsConnection } = createMoshStatsConnectionApi({
get sessions() { return sessions; },
SSHClient, sshUtils, NetcattyAgent, buildAlgorithms, getSshAgentSocket, prepareSystemSshAgentForAuth,
readFileNoFollow, expandIdentityFilePath, isAutoFillablePasswordChallenge,
hostKeyVerifier, isHostKeyTrustedBySystem, log,
});
const { createSessionOpsApi } = require("./sshBridge/sessionOps.cjs");
const measureTcpConnectLatency = createTcpConnectLatencyProbe({ net });
const sessionOpsApi = createSessionOpsApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
fs, path, os, exec, randomUUID, iconv, Buffer, process, console, setTimeout, clearTimeout,
getSessionDecoder, resetSessionDecoders, sessionEncodings, normalizeTerminalEncoding,
safeSend,
quoteShellArg, log, ensureMoshStatsConnection, ensureEtStatsConnection,
measureTcpConnectLatency,
execOnEtSession: (...args) => require("./terminalBridge.cjs").execOnEtSession(...args),
getServerStats: undefined,
});
const {
getSessionRemoteInfo,
getSessionDistroInfo,
readRemoteHistory,
getSessionPwd,
probeReceiveConflicts,
removeRemoteFiles,
restoreRemoteModes,
listSessionDir,
getServerStats,
setSessionEncoding,
} = sessionOpsApi;
/**
* Register IPC handlers for SSH operations
*/
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
ipcMain.handle(channel, async (event, payload) => {
// SSH sessions run in utilityProcess; UDP-probe LAN access from the main
// process first so macOS can show the Local Network privacy alert for
// the Netcatty app bundle instead of silently denying the helper
// (#2663 / #2673 / TN3179). Mark the payload so the worker skips a
// second hold / probe in its own process.
let workerPayload = payload;
if (channel === "netcatty:start") {
const probeResult = await ensureMacLocalNetworkAccess(payload);
workerPayload = {
...attachMacLocalNetworkProbeResult(
payload && typeof payload === "object" ? payload : {},
probeResult,
),
_macLocalNetworkMainProbed: true,
};
}
return terminalWorkerManager.request(channel, workerPayload, {
webContentsId: event?.sender?.id,
});
});
}
function registerOwnedAuthResponseHandler(ipcMain, terminalWorkerManager, channel, mainProcessHandler) {
ipcMain.handle(channel, (event, payload) => {
if (mainProcessHandler.getRequests().has(payload?.requestId)) {
return mainProcessHandler.handleResponse(event, payload);
}
return terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
});
});
}
function registerHandlers(ipcMain, options = {}) {
const terminalWorkerManager = options.terminalWorkerManager || null;
if (terminalWorkerManager) {
[
"netcatty:start",
"netcatty:ssh:exec",
"netcatty:ssh:pwd",
"netcatty:ssh:remoteInfo",
"netcatty:ssh:distroInfo",
"netcatty:ssh:readRemoteHistory",
"netcatty:ssh:listdir",
"netcatty:ssh:stats",
"netcatty:ssh:setEncoding",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel));
registerOwnedAuthResponseHandler(
ipcMain,
terminalWorkerManager,
"netcatty:keyboard-interactive:respond",
keyboardInteractiveHandler,
);
registerOwnedAuthResponseHandler(
ipcMain,
terminalWorkerManager,
"netcatty:passphrase:respond",
passphraseHandler,
);
registerOwnedAuthResponseHandler(
ipcMain,
terminalWorkerManager,
"netcatty:host-key:respond",
hostKeyVerifier,
);
ipcMain.on("netcatty:zmodem:overwrite-response", (event, payload) => {
terminalWorkerManager.send("netcatty:zmodem:overwrite-response", payload, {
webContentsId: event?.sender?.id,
});
});
} else {
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:ssh:remoteInfo", getSessionRemoteInfo);
ipcMain.handle("netcatty:ssh:distroInfo", getSessionDistroInfo);
ipcMain.handle("netcatty:ssh:readRemoteHistory", readRemoteHistory);
ipcMain.handle("netcatty:ssh:listdir", listSessionDir);
ipcMain.handle("netcatty:ssh:stats", getServerStats);
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
const resolve = zmodemOverwritePending.get(payload?.requestId);
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
// Register the passphrase response handler
passphraseHandler.registerHandler(ipcMain);
// Register the SSH host key verification response handler
hostKeyVerifier.registerHandler(ipcMain);
}
ipcMain.handle("netcatty:key:generate", generateKeyPair);
ipcMain.handle("netcatty:sshDebugLog:info", getSshDebugLogInfo);
ipcMain.handle("netcatty:sshDebugLog:openDir", openSshDebugLogDir);
ipcMain.handle("netcatty:ssh:check-agent", async (_event, options = {}) => {
const identityAgent = typeof options === "string" ? options : options.identityAgent;
if (process.platform === "win32" && !identityAgent) {
return await checkWindowsSshAgent();
}
const socketPath = options?.agentForwarding
? await getAvailableForwardingAgentSocket(identityAgent, typeof options === "string" ? {} : options)
: await getAvailableSystemAgentSocket(identityAgent, typeof options === "string" ? {} : options);
return {
running: Boolean(socketPath),
startupType: socketPath ? "running" : "stopped",
error: socketPath ? null : "SSH Agent socket not connectable",
};
});
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
try {
const entries = await fs.promises.readdir(sshDir);
const names = entries.filter(f => SSH_KEY_PATTERN.test(f));
// Preferred first, then rest alphabetically
const preferred = PREFERRED_KEY_NAMES.filter(n => names.includes(n));
const rest = names.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
for (const name of [...preferred, ...rest]) {
keys.push({ name, path: path.join(sshDir, name) });
}
} catch {
// ~/.ssh doesn't exist
}
return keys;
});
}
module.exports = {
init,
registerHandlers,
connectThroughChain,
buildAlgorithms,
_resetAlgorithmSupportCacheForTests,
_appendSshDiagnosticLog: appendSshDiagnosticLog,
_createSshDiagnosticLogger: createSshDiagnosticLogger,
_getSshDebugLogFilePath: getSshDebugLogFilePath,
_setSshDebugLoggingEnabled: setSshDebugLoggingEnabled,
_shouldLogSshDebugMessage: shouldLogSshDebugMessage,
// Exposed for the default-key dedupe characterization test (the connect path
// derives the preferred default key from findAllDefaultPrivateKeys()[0]).
_findDefaultPrivateKey: findDefaultPrivateKey,
_findAllDefaultPrivateKeys: findAllDefaultPrivateKeys,
_isStrictAgentAuthFailure: isStrictAgentAuthFailure,
_canRetryWithEncryptedDefaultKeys: canRetryWithEncryptedDefaultKeys,
_canFailedHopRetryWithEncryptedDefaultKeys: canFailedHopRetryWithEncryptedDefaultKeys,
ensureMoshStatsConnection,
};