Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1575 lines
61 KiB
JavaScript
1575 lines
61 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|