/* eslint-disable no-undef */ const { resolveSshConnectionTimeouts } = require("../sshBridge/startSession.cjs"); const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs"); const { normalizeFileProtocol } = require("./scpShell.cjs"); const { getScpBackendForClient } = require("./scpBackend.cjs"); const { executeBoundedSshCommand, openBoundedSshExecStreamCallback, terminateSshExecStream, } = require("../boundedSshExec.cjs"); const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs"); const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs"); /** Bound shell/scp probes so a hung remote exec cannot leave the panel connecting forever. */ const SCP_PROBE_TIMEOUT_MS = 15_000; /** * AbortSignal that fires on parent abort or after timeoutMs. * Caller must dispose() to clear the timer. */ function createBoundedProbeSignal(parentSignal = null, timeoutMs = SCP_PROBE_TIMEOUT_MS) { const ac = new AbortController(); let timedOut = false; let timer = null; const onParentAbort = () => { try { ac.abort(); } catch { /* ignore */ } }; if (parentSignal?.aborted) { ac.abort(); } else { if (parentSignal && typeof parentSignal.addEventListener === "function") { parentSignal.addEventListener("abort", onParentAbort, { once: true }); } timer = setTimeout(() => { timedOut = true; try { ac.abort(); } catch { /* ignore */ } }, Math.max(1, Number(timeoutMs) || SCP_PROBE_TIMEOUT_MS)); if (typeof timer.unref === "function") timer.unref(); } return { signal: ac.signal, get timedOut() { return timedOut; }, dispose: () => { if (timer) clearTimeout(timer); if (parentSignal && typeof parentSignal.removeEventListener === "function") { try { parentSignal.removeEventListener("abort", onParentAbort); } catch { /* ignore */ } } }, }; } /** Return true when ssh2 reports an authentication-stage failure. */ function isSftpAuthError(err) { const level = String(err?.level || "").toLowerCase(); const message = String(err?.message || "").toLowerCase(); return level.includes("authentication") || message.includes("authentication"); } /** Decide whether the SFTP target should retry with keyboard-interactive before password. */ function shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err) { return Boolean( options?.password && !options?._skipPasswordMethod && isSftpAuthError(err) && authConfig?.authPhase?.retryKeyboardInteractiveFirst, ); } function shouldRegisterFreshSftpTransport(options) { return options?.reuseTransport !== false && !options?.sudo; } /** Sudo is part of the requested connection contract; never downgrade it. */ async function openRequiredSudoSftp({ connectSudoSftp, sshClient, password, closeFreshClient, }) { try { return await connectSudoSftp(sshClient, password); } catch (error) { closeFreshClient(); throw error; } } function createOpenConnectionApi(ctx) { with (ctx) { /** * Mark a session client as SCP-mode and optionally probe shell access. */ async function activateScpMode(client, { probe = true, signal = null, timeoutMs = SCP_PROBE_TIMEOUT_MS } = {}) { client.__netcattyFileProtocol = "scp"; client.sftp = null; // Ensure closeSftp can tear down owned SSH sockets even when sftp is null. // Never end a shared terminal session socket. if (typeof client.end === "function" && !client.__netcattyScpEndWrapped) { const prevEnd = client.end.bind(client); client.end = async (...args) => { const ownsSocket = !client.__netcattySessionBacked && !client.__netcattySourceSessionId && !client.__netcattyTransportManaged; if (ownsSocket) { try { client.client?.end?.(); } catch { /* ignore */ } try { client.client?.destroy?.(); } catch { /* ignore */ } } return prevEnd(...args); }; client.__netcattyScpEndWrapped = true; } if (probe) { const bounded = createBoundedProbeSignal(signal, timeoutMs); try { const backend = getScpBackendForClient(client); // Require a working shell AND scp binary — transfers need `scp -t/-f`. await backend.homeDir({ signal: bounded.signal }).catch(async () => { if (bounded.signal.aborted) throw new Error("SCP mode shell probe aborted"); const adapters = require("./scpBackend.cjs").createSshExecAdapters(client.client); const pwd = await adapters.exec("pwd", { signal: bounded.signal }); if (pwd.code !== 0 || !(pwd.stdout || "").trim()) { throw new Error("SCP mode shell probe failed"); } }); const adapters = require("./scpBackend.cjs").createSshExecAdapters(client.client); const scpProbe = await adapters.exec( "command -v scp >/dev/null 2>&1 || which scp >/dev/null 2>&1", { signal: bounded.signal }, ); if (scpProbe.code !== 0) { throw new Error("SCP binary not available on remote host (command -v scp failed)"); } } catch (err) { if (bounded.timedOut && !signal?.aborted) { throw new Error(`SCP mode probe timed out after ${timeoutMs}ms`); } throw err; } finally { bounded.dispose(); } } return client; } const hasUsableProxy = (proxy) => { if (!proxy) return false; if (proxy.type === "command") return !!proxy.command?.trim(); return !!(proxy.host && proxy.port); }; async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) { const sender = event.sender; const connections = []; let currentSocket = null; let activeConn = null; const defaultAgentSocket = await getAvailableAgentSocket(); const cleanupSocket = (socket) => { if (!socket) return; try { socket.end?.(); } catch { /* ignore */ } try { socket.destroy?.(); } catch { /* ignore */ } }; try { // 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}`); console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`); sendSftpProgress(sender, connId, hopLabel, 'connecting'); const conn = new SSHClient(); activeConn = conn; // Increase max listeners to prevent Node.js warning // Set to 0 (unlimited) since complex operations add many temp listeners conn.setMaxListeners(0); // Per-hop keepalive. The renderer's resolver returns either a positive // number (use it) or 0 (the host explicitly opted out, e.g. a router // whose SSH stack doesn't reply to keepalive@openssh.com). Only when // BOTH the per-hop and the target-call fields are undefined do we // fall back to 10s/3 — that path exists for older serializers that // pre-date per-host plumbing, preserving the #669 idle-NAT protection // for callers that haven't yet been updated. const hopInterval = jump.keepaliveInterval != null ? jump.keepaliveInterval : options.keepaliveInterval; const hopCountMax = jump.keepaliveCountMax != null ? jump.keepaliveCountMax : options.keepaliveCountMax; const hopIntervalMs = hopInterval == null ? 10000 : (hopInterval > 0 ? hopInterval * 1000 : 0); const hopCountMaxEffective = hopInterval == null ? 3 : (hopInterval > 0 ? (hopCountMax ?? 3) : 0); const hopConnectionTimeouts = resolveSshConnectionTimeouts(jump); // Build connection options const connOpts = { host: jump.hostname, port: jump.port || 22, username: jump.username || 'root', timeout: hopConnectionTimeouts.tcpConnectTimeoutMs, readyTimeout: 0, keepaliveInterval: hopIntervalMs, keepaliveCountMax: hopCountMaxEffective, // Enable keyboard-interactive authentication (required for 2FA/MFA) tryKeyboard: true, // Per-hop algorithm settings, mirroring sshBridge.cjs: // - `legacyAlgorithms` falls back to the target's setting // (append-only — safe to widen the hop's offer for chain // convenience). // - `skipEcdsaHostKey` and `algorithmOverrides` are strictly // per-host. They *narrow* the offered list (drop ecdsa-* or // replace a category), so propagating the leaf's setting // would break an ECDSA-required or Ed25519-only bastion. algorithms: buildSftpAlgorithms( jump.legacyAlgorithms ?? options.legacyAlgorithms, { skipEcdsaHostKey: jump.skipEcdsaHostKey, algorithmOverrides: jump.algorithmOverrides, }, ), }; connOpts.hostVerifier = hostKeyVerifier.createHostVerifier({ sender, sessionId: connId, hostname: jump.hostname, port: jump.port || 22, knownHosts: options.knownHosts, verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys, }); // Auth - support agent (certificate), key, and password fallback const hasCertificate = typeof jump.certificate === "string" && jump.certificate.trim().length > 0; const systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth(jump, `[SFTP Chain] Hop ${i + 1}:`); const identityFile = !jump.privateKey && !systemAuthAgent ? await loadFirstIdentityFileForAuth({ sender, identityFilePaths: jump.identityFilePaths, hostname: hopLabel, initialPassphrase: jump.passphrase, logPrefix: `[SFTP Chain] Hop ${i + 1}:`, onLoaded: (loaded) => { console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${loaded.keyPath}`); }, onError: (err, keyPath) => { console.warn(`[SFTP 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, logPrefix: `[SFTP Chain] Hop ${i + 1}:`, }) : 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(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`); const keyLabel = jump.label || hopLabel; const result = await passphraseHandler.requestPassphrase( sender, `SSH key for ${keyLabel}`, keyLabel, hopLabel ); if (result?.passphrase) { connOpts.passphrase = result.passphrase; } else { delete connOpts.privateKey; if (result?.cancelled) { throw new Error(`Passphrase entry cancelled for ${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 findAllDefaultPrivateKeysFromHelper(); // Build auth handler using shared helper // Pass unlocked encrypted keys from options so jump hosts can use them for retry 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: `[SFTP Chain] Hop ${i + 1}`, unlockedEncryptedKeys: systemAuthAgent && jump.identitiesOnly ? [] : options._unlockedEncryptedKeys || [], defaultKeys, sshAgentSocketOverride: defaultAgentSocket, allowAgentFallback: jump.useSshAgent !== false, onAuthAttempt: (method) => { sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method); }, }); applyAuthToConnOpts(connOpts, authConfig); const hopAuthPhase = authConfig.authPhase || { hadPartialSuccess: false }; // 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, }); 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; const clearAuthReadyTimer = () => { if (!authReadyTimer) return; clearTimeout(authReadyTimer); authReadyTimer = null; }; conn.once('connect', () => { runWhenProxyConnectionReady(conn._sock, () => { try { conn._sock?.setTimeout?.(0); } catch { /* ignore */ } clearAuthReadyTimer(); authReadyTimer = setTimeout( () => conn.emit('timeout'), hopConnectionTimeouts.authReadyTimeoutMs, ); authReadyTimer.unref?.(); }); }); conn.once('handshake', () => { sendSftpProgress(sender, connId, hopLabel, 'authenticating'); }); conn.once('ready', () => { if (settled) return; settled = true; clearAuthReadyTimer(); console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`); sendSftpProgress(sender, connId, hopLabel, 'connected'); resolve(); }); conn.on('error', (err) => { // Filter out non-fatal agent auth errors (same as in openSftp) if (err.level === 'agent') { console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message); return; } if (settled) return; settled = true; clearAuthReadyTimer(); console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message); sendSftpProgress(sender, connId, hopLabel, 'error', err.message); reject(err); }); conn.once('timeout', () => { if (settled) return; settled = true; clearAuthReadyTimer(); console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`); reject(new Error(`Connection timeout to ${hopLabel}`)); }); conn.once('close', () => { if (settled) return; settled = true; clearAuthReadyTimer(); reject(new Error(`Connection closed before authentication completed for ${hopLabel}`)); }); let authBanner = ""; conn.on('banner', (message) => { authBanner = String(message || "").trim(); }); // Handle keyboard-interactive authentication for jump hosts (2FA/MFA) const sftpChainKiHandler = createKeyboardInteractiveHandler({ sender, sessionId: connId, hostId: jump.hostId, hostname: hopLabel, password: jump.password, logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`, scope: "external", getAuthBanner: () => authBanner, shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase), }); conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { if (prompts && prompts.length > 0) { sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...'); } const wrappedFinish = (...args) => { sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded'); finish(...args); }; sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish); }); conn.connect(connOpts); }); connections.push(conn); activeConn = null; // Determine next target let nextHost, nextPort, nextConnectionTimeouts; if (isLast) { // Last jump host, forward to final target nextHost = targetHost; nextPort = targetPort; nextConnectionTimeouts = resolveSshConnectionTimeouts(options); } 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(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`); currentSocket = await new Promise((resolve, reject) => { let settled = false; const timeout = setTimeout(() => { if (settled) return; settled = true; reject(new Error(`Connection timeout to ${nextHost}:${nextPort}`)); }, nextConnectionTimeouts.tcpConnectTimeoutMs); timeout.unref?.(); openBoundedForwardOutCallback(conn, '127.0.0.1', 0, nextHost, nextPort, (err, stream) => { if (settled) { try { stream?.end?.(); } catch { /* ignore */ } return; } settled = true; clearTimeout(timeout); if (err) { console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut failed:`, err.message); reject(err); return; } console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut success`); resolve(stream); }); }); } // Return the final forwarded stream and all connections for cleanup return { socket: currentSocket, connections }; } catch (err) { // Cleanup on error if (activeConn && !connections.includes(activeConn)) { try { activeConn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); } } cleanupSocket(currentSocket); for (const conn of connections) { try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); } } throw err; } } /** * Establish an SFTP connection using sudo * @param {SSHClient} client - Connected SSH client * @param {string} password - User password for sudo */ async function connectSudoSftp(client, password) { if (!SFTPWrapper) { throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings."); } // Known sftp-server paths to try const sftpPaths = [ "/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/lib/ssh/sftp-server", "/usr/libexec/sftp-server", "/usr/local/libexec/sftp-server", "/usr/local/lib/sftp-server" ]; console.log("[SFTP] Probing sftp-server path for sudo mode..."); let serverPath = null; // Try to find the path for (const p of sftpPaths) { try { const result = await executeBoundedSshCommand(client, `test -x ${p}`, { openingTimeoutMs: SCP_PROBE_TIMEOUT_MS, runTimeoutMs: SCP_PROBE_TIMEOUT_MS, maxOutputBytes: 16 * 1024, }); if (result.code !== 0) throw new Error('Not found'); serverPath = p; break; } catch (e) { // Continue probing } } if (!serverPath) { // Fallback: try to find it in path or assume standard location console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server"); serverPath = "/usr/lib/openssh/sftp-server"; } else { console.log(`[SFTP] Found sftp-server at ${serverPath}`); } return new Promise((resolve, reject) => { // Use sudo -S to read password from stdin // Use -p '' to set a specific prompt we can detect // Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server // We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper const prompt = "SUDOPASSWORD:"; const readyMarker = "SFTPREADY"; const readyMarkerBuffer = Buffer.from(readyMarker); // Add -e to sftp-server to log to stderr for debugging const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`; console.log(`[SFTP] Executing sudo command: ${cmd}`); // Disable pty to ensure clean binary stream for SFTP openBoundedSshExecStreamCallback(client, cmd, { pty: false }, (err, stream) => { if (err) return reject(err); // Add stream lifecycle logging stream.on('close', () => console.log("[SFTP] Stream closed")); stream.on('end', () => console.log("[SFTP] Stream ended")); stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message)); let sftpInitialized = false; let sftp = null; let settled = false; let stdoutBuffer = Buffer.alloc(0); let stderrBuffer = ""; let pendingAfterMarker = null; let sftpCreated = false; let initDelayId = null; const timeoutMs = 20000; const timeoutId = setTimeout(() => { if (sftpInitialized || settled) return; settled = true; stream.stderr?.removeListener('data', onStderr); stream.removeListener('data', onStdout); if (initDelayId) clearTimeout(initDelayId); terminateSshExecStream(stream); const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges."); reject(error); }, timeoutMs); timeoutId.unref?.(); const finalize = (err, result) => { if (settled) return; settled = true; clearTimeout(timeoutId); if (initDelayId) clearTimeout(initDelayId); initDelayId = null; stream.stderr?.removeListener('data', onStderr); stream.removeListener('data', onStdout); if (err) { terminateSshExecStream(stream); reject(err); } else resolve(result); }; const createSftp = () => { if (sftpCreated) return; sftpCreated = true; try { const chanInfo = { type: 'sftp', incoming: stream.incoming, outgoing: stream.outgoing }; sftp = new SFTPWrapper(client, chanInfo, { // debug: (str) => console.log(`[SFTP DEBUG] ${str}`) }); // Route any remaining channel data directly into the SFTP parser if (client._chanMgr && typeof stream.incoming?.id === "number") { client._chanMgr.update(stream.incoming.id, sftp); } sftp.on('ready', () => { sftpInitialized = true; console.log("[SFTP] Protocol ready"); finalize(null, sftp); }); sftp.on('error', (err) => { console.error("[SFTP] Protocol error:", err.message); if (!sftpInitialized) { finalize(err); } }); stream.on('end', () => { try { sftp.push(null); } catch { } }); } catch (e) { console.error("[SFTP] Initialization failed:", e.message); finalize(e); } }; const initSftp = () => { if (sftpInitialized) return; console.log("[SFTP] Sudo success, initializing SFTP protocol..."); if (!sftpCreated) createSftp(); try { // Start the handshake console.log("[SFTP] Sending INIT packet..."); sftp._init(); if (pendingAfterMarker && pendingAfterMarker.length > 0) { try { sftp.push(pendingAfterMarker); } catch (pushErr) { console.warn("[SFTP] Failed to push buffered data:", pushErr.message); } pendingAfterMarker = null; } } catch (e) { console.error("[SFTP] Initialization failed:", e.message); finalize(e); } }; const onStdout = (data) => { const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data); stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk; const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer); if (markerIndex !== -1) { const afterMarkerIndex = markerIndex + readyMarkerBuffer.length; if (afterMarkerIndex < stdoutBuffer.length) { pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex); } // Found marker, stop listening to stdout here so SFTPWrapper can take over stream.removeListener('data', onStdout); stdoutBuffer = Buffer.alloc(0); console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize..."); // Delay SFTP initialization to ensure sftp-server is fully started and stream is clean // Increased timeout to 1000ms to be safe initDelayId = setTimeout(() => { initDelayId = null; initSftp(); }, 1000); initDelayId.unref?.(); } else if (stdoutBuffer.length > 256) { stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256); } }; const onStderr = (data) => { const raw = Buffer.isBuffer(data) ? data : Buffer.from(String(data)); const chunk = raw.subarray(Math.max(0, raw.length - 1024)).toString(); // Only log that we received stderr data, not the content (may contain sensitive prompts) stderrBuffer = (stderrBuffer + chunk).slice(-1024); if (stderrBuffer.includes(prompt)) { console.log("[SFTP] Sudo requested password, sending..."); // Send password if (password) { stream.write(password + '\n'); } else { console.warn('[SFTP] sudo requested password but none provided'); stream.write('\n'); } stderrBuffer = ""; } else if (stderrBuffer.length > 256) stderrBuffer = stderrBuffer.slice(-256); }; stream.on('data', onStdout); stream.stderr.on('data', onStderr); // Error handling stream.on('exit', (code) => { console.log(`[SFTP] Stream exited with code ${code}`); if (!sftpInitialized && code !== 0) { let errorMsg = `SFTP sudo failed with exit code ${code}.`; if (code === 1) { errorMsg += " The password may be incorrect or sudo privileges are denied."; } else if (code === 127) { errorMsg += " sftp-server was not found on the remote system."; } const error = new Error(errorMsg); finalize(error); } }); stream.on('error', (error) => { if (!sftpInitialized) finalize(error); }); stream.on('close', () => { if (!sftpInitialized) finalize(new Error("SFTP sudo stream closed before protocol initialization")); }); stream.on('end', () => { if (!sftpInitialized) finalize(new Error("SFTP sudo stream ended before protocol initialization")); }); }, { openingTimeoutMs: 20_000 }); }); } /** * Open a new SFTP connection * Supports jump host connections when options.jumpHosts is provided */ async function openSftp(event, options) { const connId = options.sessionId || randomUUID(); const reuseEndpoint = buildConnectionReuseEndpoint(options, { sftpSudo: Boolean(options.sudo), }); let pendingDialCoordination = options._pendingDialCoordination || null; const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => { const refHolder = { id: connId, __sshLeaseKind: "sftp", webContentsId: event.sender?.id, }; acquireConnectionRef?.(refHolder, transport); const reusedClient = createSessionBackedSftpClient(connId, transport.conn, { refHolder, sourceSessionId: options.sourceSessionId || null, }); reusedClient.__netcattyEndpointKey = transport.endpointKey || buildEndpointKey(reuseEndpoint); reusedClient.__netcattyTransportManaged = true; const fileProtocol = normalizeFileProtocol(options.fileProtocol); try { sendSftpProgress(event.sender, connId, options.hostname, "connecting", detail); if (fileProtocol === "scp") { await activateScpMode(reusedClient); reusedClient.__netcattySudoMode = false; sftpClients.set(connId, reusedClient); sendSftpProgress(event.sender, connId, options.hostname, "connected", `${detail} (SCP mode)`); return { sftpId: connId, fileProtocol: "scp" }; } try { await requireSftpChannel(reusedClient); reusedClient.__netcattyFileProtocol = "sftp"; reusedClient.__netcattySudoMode = false; sftpClients.set(connId, reusedClient); sendSftpProgress(event.sender, connId, options.hostname, "connected", detail); return { sftpId: connId, fileProtocol: "sftp" }; } catch (sftpErr) { if (fileProtocol === "sftp") throw sftpErr; await activateScpMode(reusedClient); reusedClient.__netcattySudoMode = false; sftpClients.set(connId, reusedClient); sendSftpProgress(event.sender, connId, options.hostname, "connected", `${detail} (SCP mode)`); return { sftpId: connId, fileProtocol: "scp" }; } } catch (err) { try { await reusedClient.end(); } catch { /* ignore */ } throw err; } }; // Parked / shared transport without a live terminal session: open an SFTP // channel on the registry transport (no second MFA). // Dedicated bulk transfer / restart-resume passes reuseTransport:false so // a transfer never silently attaches to a terminal/parked conn. if ( !pendingDialCoordination && options.reuseTransport !== false && !options.sudo && typeof findTransportByEndpoint === "function" && typeof createSessionBackedSftpClient === "function" ) { const transport = findTransportByEndpoint(reuseEndpoint); if (transport?.conn) { try { return await openOnSharedTransport(transport, "reused parked transport"); } catch (parkErr) { console.warn( `[SFTP] Parked transport reuse failed for ${connId}; connecting fresh:`, parkErr?.message || String(parkErr), ); } } } if ( !pendingDialCoordination && options.reuseTransport !== false && !options.sudo && typeof beginTransportDial === "function" ) { const coordination = beginTransportDial(reuseEndpoint, { kind: "channel" }); if (coordination.role === "reuse" || coordination.role === "join") { try { const transport = coordination.role === "reuse" ? coordination.transport : await waitForTransportDial(coordination); return await openOnSharedTransport(transport, "reused coordinated transport"); } catch (coordinationErr) { if (coordination.role === "join") throw coordinationErr; console.warn( `[SFTP] Coordinated transport reuse failed for ${connId}; connecting fresh:`, coordinationErr?.message || String(coordinationErr), ); } } else { pendingDialCoordination = coordination; } } try { return await openFreshSftp(); } catch (err) { if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err); throw err; } async function openFreshSftp() { const client = new SftpClient(); client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint); let freshClientClosed = false; const closeFreshClient = () => { if (freshClientClosed) return; freshClientClosed = true; // A failure after SSH authentication but before registration in // sftpClients/the transport pool otherwise leaves an unreachable live // socket. This is deliberately low-level: there may be no SFTP channel // for ssh2-sftp-client.end() to close yet. try { client.client?.end?.(); } catch { /* ignore */ } try { client.client?.destroy?.(); } catch { /* ignore */ } }; // Get default keys early to use for both chain and target const defaultKeys = await findAllDefaultPrivateKeysFromHelper(); // Check if we need to connect through jump hosts const jumpHosts = options.jumpHosts || []; const hasJumpHosts = jumpHosts.length > 0; const hasProxy = hasUsableProxy(options.proxy); const targetConnectionTimeouts = resolveSshConnectionTimeouts(options); let chainConnections = []; let connectionSocket = null; const cleanupPendingConnection = () => { for (const conn of chainConnections) { try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); } } if (connectionSocket) { try { connectionSocket.end?.(); } catch { /* ignore */ } try { connectionSocket.destroy?.(); } catch { /* ignore */ } } }; // Pre-fetch agent socket (async check for Windows SSH Agent service) // This is used by both jump host chain auth and final host auth const agentSocket = await getAvailableAgentSocket(options.identityAgent); // Handle chain/proxy connections if (hasJumpHosts) { console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`); // Pass default keys to chain connection options._defaultKeys = defaultKeys; const chainResult = await connectThroughChainForSftp( event, options, jumpHosts, options.hostname, options.port || 22, connId ); connectionSocket = chainResult.socket; chainConnections = chainResult.connections; } else if (hasProxy) { console.log(`[SFTP] Opening connection through proxy to ${options.hostname}:${options.port || 22}`); connectionSocket = await createProxySocket( options.proxy, options.hostname, options.port || 22, { timeoutMs: targetConnectionTimeouts.tcpConnectTimeoutMs }, ); } const keepalivePolicy = resolveConnectionKeepalivePolicy(options); const connectOpts = { host: options.hostname, port: options.port || 22, username: options.username || "root", // Enable keyboard-interactive authentication (required for 2FA/MFA) tryKeyboard: true, timeout: targetConnectionTimeouts.tcpConnectTimeoutMs, readyTimeout: 0, // Keepalive policy: // - positive value: honor it (in seconds, convert to ms) // - explicit 0: truly disabled (host opted out via per-host override — // critical for routers/switches that don't reply to keepalive // @openssh.com and would otherwise be killed by ssh2 after countMax // unanswered probes) // - undefined: legacy caller path, fall back to 10s/3 so an idle SFTP // browse over a NAT doesn't drop (the original #669 protection) keepaliveInterval: keepalivePolicy.keepaliveIntervalMs, keepaliveCountMax: keepalivePolicy.keepaliveCountMax, algorithms: buildSftpAlgorithms(options.legacyAlgorithms, { skipEcdsaHostKey: options.skipEcdsaHostKey, algorithmOverrides: options.algorithmOverrides, }), }; connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({ sender: event.sender, sessionId: connId, hostId: options.hostId, hostname: options.hostname, port: options.port || 22, knownHosts: options.knownHosts, verifyHostKeys: options.verifyHostKeys, }); // Use the tunneled socket if we have one if (connectionSocket) { connectOpts.sock = connectionSocket; // When using sock, we should not set host/port as the connection is already established delete connectOpts.host; delete connectOpts.port; } const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0; let identityFile = null; let inlineKey = null; let systemAuthAgent = null; try { systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth(options, "[SFTP]"); identityFile = !options.privateKey && !systemAuthAgent ? await loadFirstIdentityFileForAuth({ sender: event.sender, identityFilePaths: options.identityFilePaths, hostname: options.hostname, initialPassphrase: options.passphrase, logPrefix: "[SFTP]", onLoaded: (loaded) => { console.log(`[SFTP] Loaded identity file ${loaded.keyPath}`); }, onError: (err, keyPath) => { console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message); }, }) : null; inlineKey = options.privateKey && !systemAuthAgent ? await preparePrivateKeyForAuth({ sender: event.sender, privateKey: options.privateKey, keyId: options.keyId, keyName: options.keyId || options.username, hostname: options.hostname, initialPassphrase: options.passphrase, logPrefix: "[SFTP]", }) : null; } catch (err) { cleanupPendingConnection(); throw err; } const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey; const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase; let authAgent = null; 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: effectivePassphrase, }, }); connectOpts.agent = authAgent; } else if (effectivePrivateKey) { connectOpts.privateKey = effectivePrivateKey; if (effectivePassphrase) { connectOpts.passphrase = effectivePassphrase; } else if (isKeyEncrypted(effectivePrivateKey)) { // Key is encrypted but no passphrase provided — prompt the user console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`); const result = await passphraseHandler.requestPassphrase( event.sender, `SSH key for ${options.hostname}`, options.hostname, options.hostname ); if (result?.passphrase) { connectOpts.passphrase = result.passphrase; } else { delete connectOpts.privateKey; if (result?.cancelled) { // Clean up any chain/proxy connections and proxy socket opened earlier for (const c of chainConnections) { try { c.end(); } catch {} } if (connectionSocket) { try { connectionSocket.destroy(); } catch {} } // Use "authentication" in the message so the SFTP frontend's // isAuthError() check recognizes this and falls back to password. const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`); err.level = 'client-authentication'; throw err; } } } } if (options.password) connectOpts.password = options.password; // Build auth handler using shared helper // Use pre-fetched agentSocket (validated async, including Windows service check) const authConfig = buildAuthHandler({ authMethod: options.authMethod, requiresMfa: !!options.requiresMfa, privateKey: connectOpts.privateKey, password: connectOpts.password, passphrase: connectOpts.passphrase, agent: connectOpts.agent, username: connectOpts.username, logPrefix: "[SFTP]", skipPasswordMethod: options._skipPasswordMethod === true, defaultKeys: systemAuthAgent && options.identitiesOnly ? [] : defaultKeys, sshAgentSocketOverride: agentSocket, allowAgentFallback: options.useSshAgent !== false, onAuthAttempt: (method) => { sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method); }, }); applyAuthToConnOpts(connectOpts, authConfig); const sftpAuthPhase = authConfig.authPhase || { hadPartialSuccess: false }; let authBanner = ""; // Create keyboard-interactive handler using shared helper const kiHandler = createKeyboardInteractiveHandler({ sender: event.sender, sessionId: connId, hostId: options.hostId, hostname: options.hostname, password: options.password, logPrefix: "[SFTP]", scope: "external", getAuthBanner: () => authBanner, shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(sftpAuthPhase), }); // Add keyboard-interactive listener BEFORE connecting // Wrap to emit progress events for the SFTP connection log client.on("banner", (message) => { authBanner = String(message || "").trim(); }); client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => { if (prompts && prompts.length > 0) { sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...'); } const wrappedFinish = (...args) => { sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded'); finish(...args); }; kiHandler(name, instructions, lang, prompts, wrappedFinish); }); try { // IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the // underlying ssh2 Client directly. This is because ssh2-sftp-client adds // temporary error listeners that reject the entire connect promise on ANY // error, including non-fatal auth errors (e.g. 'Failed to connect to agent' // when ssh2 tries agent auth and falls through to the next method). // By connecting directly, we can filter these non-fatal errors and allow // the auth flow to continue to keyboard-interactive/password/etc. const sshClient = client.client; await new Promise((resolve, reject) => { let settled = false; let authReadyTimer = null; const clearAuthReadyTimer = () => { if (!authReadyTimer) return; clearTimeout(authReadyTimer); authReadyTimer = null; }; const settle = (fn, val) => { if (settled) return; settled = true; clearAuthReadyTimer(); cleanup(); fn(val); }; const onError = (err) => { // Filter out non-fatal authentication errors. // ssh2 sets err.level = 'agent' when agent auth fails — it then // internally calls tryNextAuth() to proceed with the next method. // We must NOT reject here, or the fallback won't execute. if (err.level === 'agent') { console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message); return; } settle(reject, err); }; const onEnd = () => { settle(reject, new Error('Connection closed before SFTP session was ready')); }; const onClose = () => { settle(reject, new Error('Connection closed before SFTP session was ready')); }; const cleanup = () => { sshClient.removeListener('error', onError); sshClient.removeListener('end', onEnd); sshClient.removeListener('close', onClose); sshClient.removeListener('timeout', onTimeout); // Keep a catch-all error listener so post-ready errors (e.g. connection // drops during an active SFTP session) don't become uncaught exceptions. sshClient.on('error', (err) => { console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message); }); }; sshClient.on('error', onError); sshClient.on('end', onEnd); sshClient.on('close', onClose); const onTimeout = () => { settle(reject, new Error(`Connection timeout to ${options.hostname}`)); try { sshClient.end?.(); } catch { /* ignore */ } try { sshClient.destroy?.(); } catch { /* ignore */ } }; sshClient.on('timeout', onTimeout); sshClient.once('connect', () => { runWhenProxyConnectionReady(sshClient._sock, () => { try { sshClient._sock?.setTimeout?.(0); } catch { /* ignore */ } clearAuthReadyTimer(); authReadyTimer = setTimeout( () => sshClient.emit('timeout'), targetConnectionTimeouts.authReadyTimeoutMs, ); authReadyTimer.unref?.(); }); }); sshClient.once('handshake', () => { sendSftpProgress(event.sender, connId, options.hostname, 'authenticating'); }); sshClient.once('ready', () => { clearAuthReadyTimer(); cleanup(); sendSftpProgress(event.sender, connId, options.hostname, 'connected'); const fileProtocol = normalizeFileProtocol(options.fileProtocol); const finishSftp = (sftp) => { client.sftp = sftp; client.__netcattyFileProtocol = "sftp"; resolve(); }; const finishScp = async (reason) => { try { if (reason) { console.warn(`[SFTP] ${reason}; activating SCP mode for ${connId}`); } await activateScpMode(client); resolve(); } catch (scpErr) { closeFreshClient(); reject(scpErr); } }; if (fileProtocol === "scp") { if (options.sudo) { // Forced SCP cannot provide sudo elevation; reject contradictory host data. closeFreshClient(); reject(new Error( "Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.", )); return; } console.log(`[SFTP] Forced SCP mode for connection: ${connId}`); void finishScp(null); return; } // When the host asks for sudo SFTP, never silently fall through to // unprivileged SCP on Auto — that would look "connected" without elevation. if (options.sudo) { console.log(`[SFTP] Using sudo mode for connection: ${connId}`); (async () => { try { const sudoPass = options.password || ""; const sftpWrapper = await openRequiredSudoSftp({ connectSudoSftp, sshClient, password: sudoPass, closeFreshClient, }); sftpWrapper.on('close', () => client.end()); finishSftp(sftpWrapper); } catch (e) { // Elevation is part of the requested connection contract. A // standard SFTP fallback would look successful while silently // losing access to the requested destination. reject(e); } })(); } else { // Open standard SFTP subsystem channel openBoundedSftpChannel(sshClient).then((sftp) => { finishSftp(sftp); }).catch((err) => { if (fileProtocol === "auto") { void finishScp(`SFTP subsystem unavailable (${err.message})`); return; } reject(err); }); } }); sendSftpProgress(event.sender, connId, options.hostname, 'connecting'); try { sshClient.connect(connectOpts); } catch (e) { settle(reject, e); } }); // Increase max listeners AFTER connect, when the internal ssh2 Client exists // This prevents Node.js MaxListenersExceededWarning when performing many operations // ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit if (client.client && typeof client.client.setMaxListeners === 'function') { client.client.setMaxListeners(0); // 0 means unlimited } // Used by transferBridge to decide whether isolated fast-transfer channels are safe. client.__netcattySudoMode = !!options.sudo && client.__netcattyFileProtocol !== "scp"; if (!client.__netcattyFileProtocol) { client.__netcattyFileProtocol = "sftp"; } // Register dedicated dials in the shared SSH transport registry so // later terminal/SFTP/transfer/port-forward work can borrow without MFA. const sshConn = client.client; if ( sshConn && shouldRegisterFreshSftpTransport(options) && typeof createTransport === "function" && typeof borrowTransport === "function" ) { try { const refHolder = { id: connId, __sshLeaseKind: "sftp", webContentsId: event.sender?.id, }; const transport = createTransport({ conn: sshConn, chainConnections, endpoint: reuseEndpoint, }); borrowTransport(transport, { kind: "sftp", holder: refHolder, leaseId: `sftp:${connId}`, meta: { source: "openSftp-dedicated" }, }); client.__netcattyRefHolder = refHolder; client.__netcattyTransportManaged = true; if (pendingDialCoordination) { completeTransportDial(pendingDialCoordination, transport); } // Chain lifetime is owned by the transport; do not double-end on close. chainConnections = []; } catch (regErr) { if (pendingDialCoordination) { failTransportDial(pendingDialCoordination, regErr); } console.warn( `[SFTP] Failed to register dedicated transport for ${connId}:`, regErr?.message || String(regErr), ); } } sftpClients.set(connId, client); // Store jump connections for cleanup when SFTP is closed (legacy path // only — transport-managed connections park chain on the registry). if (chainConnections.length > 0 && !client.__netcattyTransportManaged) { jumpConnectionsMap.set(connId, { connections: chainConnections, socket: connectionSocket }); } console.log(`[SFTP] Connection established: ${connId} (protocol=${client.__netcattyFileProtocol})`); return { sftpId: connId, fileProtocol: client.__netcattyFileProtocol }; } catch (err) { // Cleanup jump connections on error cleanupPendingConnection(); closeFreshClient(); if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) { console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first..."); return openSftp(event, { ...options, _skipPasswordMethod: true, _pendingDialCoordination: pendingDialCoordination, }); } if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err); throw err; } } } return { connectThroughChainForSftp, connectSudoSftp, openSftp }; } } module.exports = { createOpenConnectionApi, createBoundedProbeSignal, shouldRegisterFreshSftpTransport, SCP_PROBE_TIMEOUT_MS, };