fix: harden SSH connection reliability and add passphrase prompt (#636)

This commit is contained in:
Jinwoo Hong
2026-04-14 18:29:50 -07:00
committed by GitHub
parent 8c016c91b2
commit 42e04268fb
37 changed files with 2064 additions and 749 deletions
+103 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { parseSshConfig, sshConfigHostsToTargets } from './ssh-config-parser'
import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
@@ -209,3 +209,105 @@ describe('sshConfigHostsToTargets', () => {
expect(targets[0].jumpHost).toBe('bastion.example.com')
})
})
// ── parseSshGOutput ──────────────────────────────────────────────────
describe('parseSshGOutput', () => {
it('parses hostname, user, port from ssh -G output', () => {
const output = [
'hostname 192.168.1.100',
'user deploy',
'port 2222',
'identityfile /home/testuser/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
const result = parseSshGOutput(output)
expect(result.hostname).toBe('192.168.1.100')
expect(result.user).toBe('deploy')
expect(result.port).toBe(2222)
expect(result.identityFile).toEqual(['/home/testuser/.ssh/id_ed25519'])
expect(result.forwardAgent).toBe(false)
})
it('collects multiple identity files', () => {
const output = [
'hostname example.com',
'identityfile ~/.ssh/id_ed25519',
'identityfile ~/.ssh/id_rsa',
'port 22'
].join('\n')
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual([
'/home/testuser/.ssh/id_ed25519',
'/home/testuser/.ssh/id_rsa'
])
})
it('parses forwardagent yes', () => {
const output = 'hostname example.com\nforwardagent yes\nport 22'
const result = parseSshGOutput(output)
expect(result.forwardAgent).toBe(true)
})
it('defaults port to 22 when missing', () => {
const output = 'hostname example.com'
const result = parseSshGOutput(output)
expect(result.port).toBe(22)
})
it('returns empty hostname when missing', () => {
const output = 'user admin\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('')
})
it('returns undefined user when missing', () => {
const output = 'hostname example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.user).toBeUndefined()
})
it('handles empty output', () => {
const result = parseSshGOutput('')
expect(result.hostname).toBe('')
expect(result.port).toBe(22)
expect(result.identityFile).toEqual([])
})
it('skips lines without spaces', () => {
const output = 'hostname example.com\nbadline\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('example.com')
expect(result.port).toBe(22)
})
it('parses proxycommand and filters "none"', () => {
const output = 'hostname example.com\nproxycommand ssh -W %h:%p bastion\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyCommand).toBe('ssh -W %h:%p bastion')
const noneOutput = 'hostname example.com\nproxycommand none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyCommand).toBeUndefined()
})
it('parses proxyjump and filters "none"', () => {
const output = 'hostname example.com\nproxyjump bastion.example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyJump).toBe('bastion.example.com')
const noneOutput = 'hostname example.com\nproxyjump none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyJump).toBeUndefined()
})
it('handles ~ expansion in identity file paths', () => {
const output = 'hostname example.com\nidentityfile ~/custom_key\nport 22'
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual(['/home/testuser/custom_key'])
})
})
// Why: resolveWithSshG tests are in ssh-config-resolver.test.ts (max-lines).
+70
View File
@@ -1,4 +1,5 @@
import { readFileSync, existsSync } from 'fs'
import { execFile } from 'child_process'
import { join } from 'path'
import { homedir } from 'os'
import type { SshTarget } from '../../shared/ssh-types'
@@ -137,6 +138,7 @@ export function sshConfigHostsToTargets(
targets.push({
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
label,
configHost: entry.host,
host: effectiveHost,
port: entry.port ?? 22,
username: entry.user ?? '',
@@ -148,3 +150,71 @@ export function sshConfigHostsToTargets(
return targets
}
// ── ssh -G config resolution ──────────────────────────────────────────
export type SshResolvedConfig = {
hostname: string
user?: string
port: number
identityFile: string[]
forwardAgent: boolean
proxyCommand?: string
proxyJump?: string
}
const SSH_G_TIMEOUT_MS = 5000
// Why: `ssh -G <host>` asks OpenSSH to resolve the full effective config
// for a host, including Include directives, Match blocks, wildcard
// inheritance, and ProxyCommand expansion. This gives us correct config
// resolution without reimplementing OpenSSH's complex matching logic.
export function resolveWithSshG(host: string): Promise<SshResolvedConfig | null> {
return new Promise((resolve) => {
// Why: '--' prevents a host label starting with '-' from being interpreted
// as an SSH flag (classic argument injection vector).
execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => {
if (err) {
resolve(null)
return
}
resolve(parseSshGOutput(stdout))
})
})
}
export function parseSshGOutput(stdout: string): SshResolvedConfig {
const map = new Map<string, string>()
const identityFiles: string[] = []
for (const line of stdout.split('\n')) {
const spaceIdx = line.indexOf(' ')
if (spaceIdx === -1) {
continue
}
const key = line.substring(0, spaceIdx).toLowerCase()
const value = line.substring(spaceIdx + 1).trim()
if (key === 'identityfile') {
identityFiles.push(resolveHomePath(value))
} else {
map.set(key, value)
}
}
// Why: `ssh -G` outputs `proxycommand none` / `proxyjump none` when no
// proxy is configured. Treating "none" as real would spawn bad commands.
const rawProxy = map.get('proxycommand')
const proxyCommand = rawProxy && rawProxy !== 'none' ? rawProxy : undefined
const rawJump = map.get('proxyjump')
const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined
return {
hostname: map.get('hostname') ?? '',
user: map.get('user') || undefined,
port: parseInt(map.get('port') ?? '22', 10),
identityFile: identityFiles,
forwardAgent: map.get('forwardagent') === 'yes',
proxyCommand,
proxyJump
}
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWithSshG } from './ssh-config-parser'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
vi.mock('child_process', () => ({
execFile: vi.fn()
}))
describe('resolveWithSshG', () => {
it('returns parsed config on success', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(null, 'hostname 10.0.0.1\nuser admin\nport 22')
return undefined as never
}
)
const result = await resolveWithSshG('myhost')
expect(result).toBeDefined()
expect(result!.hostname).toBe('10.0.0.1')
expect(result!.user).toBe('admin')
})
it('calls ssh -G with the given host', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(null, 'hostname example.com\nport 22')
return undefined as never
}
)
await resolveWithSshG('testserver')
expect(mockExecFile).toHaveBeenCalledWith(
'ssh',
['-G', '--', 'testserver'],
expect.objectContaining({ timeout: 5000 }),
expect.any(Function)
)
})
it('returns null when ssh -G fails', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(new Error('ssh not found'), '')
return undefined as never
}
)
const result = await resolveWithSshG('myhost')
expect(result).toBeNull()
})
})
+2 -1
View File
@@ -16,6 +16,7 @@ export class SshConnectionStore {
addTarget(target: Omit<SshTarget, 'id'>): SshTarget {
const full: SshTarget = {
...target,
configHost: target.configHost ?? target.host,
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
this.store.addSshTarget(full)
@@ -35,7 +36,7 @@ export class SshConnectionStore {
* Returns the newly imported targets.
*/
importFromSshConfig(): SshTarget[] {
const existingLabels = new Set(this.store.getSshTargets().map((t) => t.label))
const existingLabels = new Set(this.store.getSshTargets().map((t) => t.configHost ?? t.label))
const configHosts = loadUserSshConfig()
const newTargets = sshConfigHostsToTargets(configHosts, existingLabels)
+369
View File
@@ -0,0 +1,369 @@
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
const mockExistsSync = vi.fn().mockReturnValue(false)
const mockReadFileSync = vi.fn()
vi.mock('fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args)
}))
import {
isTransientError,
isAuthError,
sleep,
shellEscape,
findDefaultKeyFile,
buildConnectConfig,
resolveEffectiveProxy,
CONNECT_TIMEOUT_MS,
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS
} from './ssh-connection-utils'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
// ── Constants ────────────────────────────────────────────────────────
describe('SSH connection constants', () => {
it('CONNECT_TIMEOUT_MS is 30 seconds (matches VS Code)', () => {
expect(CONNECT_TIMEOUT_MS).toBe(30_000)
})
it('INITIAL_RETRY_ATTEMPTS is 5', () => {
expect(INITIAL_RETRY_ATTEMPTS).toBe(5)
})
it('INITIAL_RETRY_DELAY_MS is 2 seconds', () => {
expect(INITIAL_RETRY_DELAY_MS).toBe(2000)
})
it('RECONNECT_BACKOFF_MS has 9 entries', () => {
expect(RECONNECT_BACKOFF_MS).toHaveLength(9)
})
})
// ── isTransientError ─────────────────────────────────────────────────
describe('isTransientError', () => {
it('returns true for ETIMEDOUT code', () => {
const err = new Error('timed out') as NodeJS.ErrnoException
err.code = 'ETIMEDOUT'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNREFUSED code', () => {
const err = new Error('refused') as NodeJS.ErrnoException
err.code = 'ECONNREFUSED'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNRESET code', () => {
const err = new Error('reset') as NodeJS.ErrnoException
err.code = 'ECONNRESET'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EHOSTUNREACH code', () => {
const err = new Error('host unreachable') as NodeJS.ErrnoException
err.code = 'EHOSTUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ENETUNREACH code', () => {
const err = new Error('net unreachable') as NodeJS.ErrnoException
err.code = 'ENETUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EAI_AGAIN code', () => {
const err = new Error('dns') as NodeJS.ErrnoException
err.code = 'EAI_AGAIN'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ETIMEDOUT in message (no code)', () => {
expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNREFUSED in message', () => {
expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNRESET in message', () => {
expect(isTransientError(new Error('read ECONNRESET'))).toBe(true)
})
it('returns false for auth errors', () => {
expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false)
})
it('returns false for generic errors', () => {
expect(isTransientError(new Error('something went wrong'))).toBe(false)
})
})
// ── isAuthError ──────────────────────────────────────────────────────
describe('isAuthError', () => {
it('returns true for "All configured authentication methods failed"', () => {
expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true)
})
it('returns true for "Authentication failed"', () => {
expect(isAuthError(new Error('Authentication failed'))).toBe(true)
})
it('returns true for client-authentication level', () => {
const err = new Error('auth') as Error & { level: string }
err.level = 'client-authentication'
expect(isAuthError(err)).toBe(true)
})
it('returns false for transient errors', () => {
expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false)
})
})
// ── sleep ────────────────────────────────────────────────────────────
describe('sleep', () => {
it('resolves after the given delay', async () => {
const start = Date.now()
await sleep(50)
expect(Date.now() - start).toBeGreaterThanOrEqual(40)
})
})
// ── shellEscape ──────────────────────────────────────────────────────
describe('shellEscape', () => {
it('wraps string in single quotes', () => {
expect(shellEscape('hello')).toBe("'hello'")
})
it('escapes embedded single quotes', () => {
expect(shellEscape("it's")).toBe("'it'\\''s'")
})
it('handles empty string', () => {
expect(shellEscape('')).toBe("''")
})
it('handles special characters', () => {
expect(shellEscape('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'")
})
})
// ── findDefaultKeyFile ───────────────────────────────────────────────
describe('findDefaultKeyFile', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
})
it('returns undefined when no default keys exist', () => {
expect(findDefaultKeyFile()).toBeUndefined()
})
it('returns the first existing key file', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === '/home/testuser/.ssh/id_ed25519'
})
mockReadFileSync.mockReturnValue(Buffer.from('key-contents'))
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_ed25519')
expect(result!.contents).toEqual(Buffer.from('key-contents'))
})
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
const checkedPaths: string[] = []
mockExistsSync.mockImplementation((path: unknown) => {
checkedPaths.push(String(path))
return false
})
findDefaultKeyFile()
expect(checkedPaths).toEqual([
'/home/testuser/.ssh/id_ed25519',
'/home/testuser/.ssh/id_rsa',
'/home/testuser/.ssh/id_ecdsa',
'/home/testuser/.ssh/id_dsa',
'/home/testuser/.ssh/id_xmss'
])
})
it('skips unreadable key files and tries next', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === '/home/testuser/.ssh/id_ed25519' || path === '/home/testuser/.ssh/id_rsa'
})
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === '/home/testuser/.ssh/id_ed25519') {
throw new Error('permission denied')
}
return Buffer.from('rsa-key')
})
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_rsa')
})
})
// ── buildConnectConfig ──────────────────────────────────────────────
function makeTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
id: 'test-1',
label: 'myhost',
host: 'example.com',
port: 22,
username: 'deploy',
...overrides
}
}
function makeResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig {
return {
hostname: '10.0.0.1',
port: 22,
identityFile: [],
forwardAgent: false,
...overrides
}
}
describe('buildConnectConfig', () => {
const originalEnv = process.env.SSH_AUTH_SOCK
beforeEach(() => {
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
process.env.SSH_AUTH_SOCK = '/tmp/agent.sock'
})
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SSH_AUTH_SOCK = originalEnv
} else {
delete process.env.SSH_AUTH_SOCK
}
})
it('uses target host/port/username', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.host).toBe('example.com')
expect(config.port).toBe(22)
expect(config.username).toBe('deploy')
})
it('falls back to resolved config when target fields are empty', () => {
const config = buildConnectConfig(
makeTarget({ host: '', port: 0, username: '' }),
makeResolved({ hostname: '10.0.0.1', port: 2222, user: 'admin' })
)
expect(config.host).toBe('10.0.0.1')
expect(config.port).toBe(2222)
expect(config.username).toBe('admin')
})
it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.readyTimeout).toBe(30_000)
})
it('sets keepaliveInterval to 15s', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.keepaliveInterval).toBe(15_000)
})
it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses keyFile auth when target.identityFile is set', () => {
mockReadFileSync.mockReturnValue(Buffer.from('key'))
const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null)
expect(config.privateKey).toEqual(Buffer.from('key'))
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses keyFile auth when resolved identityFile is non-default', () => {
mockReadFileSync.mockReturnValue(Buffer.from('custom-key'))
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/user/.ssh/work_key'] })
)
expect(config.privateKey).toEqual(Buffer.from('custom-key'))
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses agent auth when resolved identityFile is a default path (expanded)', () => {
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/testuser/.ssh/id_ed25519'] })
)
expect(config.agent).toBe('/tmp/agent.sock')
})
it('provides fallback key in agent auth mode', () => {
mockExistsSync.mockImplementation(
(p: unknown) => String(p) === '/home/testuser/.ssh/id_ed25519'
)
mockReadFileSync.mockReturnValue(Buffer.from('fallback'))
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toEqual(Buffer.from('fallback'))
})
})
// ── resolveEffectiveProxy ───────────────────────────────────────────
describe('resolveEffectiveProxy', () => {
it('returns target.proxyCommand first', () => {
const target = { ...makeTarget(), proxyCommand: 'cloudflared access ssh --hostname %h' }
const resolved = makeResolved({ proxyCommand: 'other' })
expect(resolveEffectiveProxy(target, resolved)).toEqual({
kind: 'proxy-command',
command: 'cloudflared access ssh --hostname %h'
})
})
it('falls back to resolved proxyCommand', () => {
expect(
resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' }))
).toEqual({
kind: 'proxy-command',
command: 'ssh -W %h:%p gw'
})
})
it('returns structured jump-host config for target.jumpHost', () => {
const target = { ...makeTarget(), jumpHost: 'bastion.example.com' }
expect(resolveEffectiveProxy(target, null)).toEqual({
kind: 'jump-host',
jumpHost: 'bastion.example.com'
})
})
it('returns structured jump-host config for resolved proxyJump', () => {
expect(resolveEffectiveProxy(makeTarget(), makeResolved({ proxyJump: 'jump.host' }))).toEqual({
kind: 'jump-host',
jumpHost: 'jump.host'
})
})
it('returns undefined when no proxy is configured', () => {
expect(resolveEffectiveProxy(makeTarget(), null)).toBeUndefined()
})
})
+163 -234
View File
@@ -1,39 +1,33 @@
import { Client as SshClient } from 'ssh2'
import type { ConnectConfig, ClientChannel } from 'ssh2'
import { type ChildProcess, execFileSync } from 'child_process'
import { readFileSync } from 'fs'
import { createHash } from 'crypto'
import { readFileSync, existsSync } from 'fs'
import { spawn, type ChildProcess } from 'child_process'
import { homedir } from 'os'
import { join } from 'path'
import { Duplex } from 'stream'
import type { Socket as NetSocket } from 'net'
import type { ConnectConfig } from 'ssh2'
import type { SshTarget, SshConnectionState } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
// Why: types live here (not ssh-connection.ts) to break a circular import.
export type HostKeyVerifyRequest = {
host: string
ip: string
fingerprint: string
keyType: string
}
export type AuthChallengeRequest = {
targetId: string
name: string
instructions: string
prompts: { prompt: string; echo: boolean }[]
}
export type SshCredentialKind = 'passphrase' | 'password'
export type SshConnectionCallbacks = {
onStateChange: (targetId: string, state: SshConnectionState) => void
onHostKeyVerify: (req: HostKeyVerifyRequest) => Promise<boolean>
onAuthChallenge: (req: AuthChallengeRequest) => Promise<string[]>
onPasswordPrompt: (targetId: string) => Promise<string | null>
onCredentialRequest?: (
targetId: string,
kind: SshCredentialKind,
detail: string
) => Promise<string | null>
}
export function isPassphraseError(err: Error): boolean {
const msg = err.message.toLowerCase()
return msg.includes('passphrase') || msg.includes('encrypted key') || msg.includes('bad decrypt')
}
export const INITIAL_RETRY_ATTEMPTS = 5
export const INITIAL_RETRY_DELAY_MS = 2000
export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000]
export const AUTH_CHALLENGE_TIMEOUT_MS = 60_000
export const CONNECT_TIMEOUT_MS = 15_000
export const CONNECT_TIMEOUT_MS = 30_000
const TRANSIENT_ERROR_CODES = new Set([
'ETIMEDOUT',
@@ -44,6 +38,15 @@ const TRANSIENT_ERROR_CODES = new Set([
'EAI_AGAIN'
])
export function isAuthError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
msg.includes('all configured authentication methods failed') ||
msg.includes('authentication failed') ||
(err as { level?: string }).level === 'client-authentication'
)
}
export function isTransientError(err: Error): boolean {
const code = (err as NodeJS.ErrnoException).code
if (code && TRANSIENT_ERROR_CODES.has(code)) {
@@ -65,235 +68,161 @@ export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// Why: prevents shell injection when interpolating into ProxyCommand.
export function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
// Why: ssh2 doesn't check known_hosts. Without this, every connection blocks
// on a UI prompt that isn't wired up yet, causing a silent timeout.
function isHostKnown(host: string, port: number): boolean {
try {
const lookup = port === 22 ? host : `[${host}]:${port}`
execFileSync('ssh-keygen', ['-F', lookup], { stdio: 'pipe', timeout: 3000 })
return true
} catch {
return false
}
function cmdEscape(s: string): string {
return `"${s.replace(/"/g, '""')}"`
}
// ── Auth handler state (passed in by the connection) ────────────────
// Why: ssh2 only tries keys that are explicitly provided. Users with keys in
// standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would
// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile.
const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss']
export type AuthHandlerState = {
agentAttempted: boolean
keyAttempted: boolean
setState: (status: string, error?: string) => void
}
const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`)
export type ConnectConfigResult = {
config: ConnectConfig
jumpClient: SshClient | null
proxyProcess: ChildProcess | null
}
export async function buildConnectConfig(
target: SshTarget,
callbacks: SshConnectionCallbacks,
authState: AuthHandlerState
): Promise<ConnectConfigResult> {
const config: ConnectConfig = {
host: target.host,
port: target.port,
username: target.username,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 5000,
keepaliveCountMax: 4,
// Why: parseSshGOutput expands ~ to homedir(), so resolved identityFile
// paths won't match the ~/... form in DEFAULT_KEY_PATHS. Pre-expand for
// the comparison in buildConnectConfig.
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => join(homedir(), '.ssh', name))
// Why: ssh2's hostVerifier callback form `(key, verify) => void` blocks
// the handshake until `verify(true/false)` is called. We check
// known_hosts first so trusted hosts connect without a UI prompt.
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
if (isHostKnown(target.host, target.port)) {
verify(true)
return
}
const fingerprint = createHash('sha256').update(key).digest('base64')
const keyType = 'unknown'
authState.setState('host-key-verification')
callbacks
.onHostKeyVerify({
host: target.host,
ip: target.host,
fingerprint,
keyType
})
.then((accepted) => {
verify(accepted)
})
.catch(() => {
verify(false)
})
},
authHandler: (methodsLeft, _partialSuccess, callback) => {
// ssh2 passes null on the first call, meaning "try whatever you want".
// Treat it as all methods available.
const methods = methodsLeft ?? ['publickey', 'keyboard-interactive', 'password']
// Try auth methods in order: agent -> publickey -> keyboard-interactive -> password
// The custom authHandler overrides ssh2's built-in sequence, so we must
// explicitly try agent auth here -- the config.agent field alone is not enough.
if (methods.includes('publickey') && process.env.SSH_AUTH_SOCK && !authState.agentAttempted) {
authState.agentAttempted = true
callback({
type: 'agent' as const,
agent: process.env.SSH_AUTH_SOCK,
username: target.username
} as never)
return
}
if (methods.includes('publickey') && target.identityFile && !authState.keyAttempted) {
authState.keyAttempted = true
try {
callback({
type: 'publickey' as const,
username: target.username,
key: readFileSync(target.identityFile)
} as never)
return
} catch {
// Key file unreadable -- fall through to next method
}
}
if (methods.includes('keyboard-interactive')) {
callback({
type: 'keyboard-interactive' as const,
username: target.username,
prompt: async (
_name: string,
instructions: string,
_lang: string,
prompts: { prompt: string; echo: boolean }[],
finish: (responses: string[]) => void
) => {
authState.setState('auth-challenge')
const timeoutPromise = sleep(AUTH_CHALLENGE_TIMEOUT_MS).then(() => null)
const responsePromise = callbacks.onAuthChallenge({
targetId: target.id,
name: _name,
instructions,
prompts
})
const responses = await Promise.race([responsePromise, timeoutPromise])
if (!responses) {
finish([])
return
}
finish(responses)
}
} as never)
return
}
if (methods.includes('password')) {
callbacks
.onPasswordPrompt(target.id)
.then((password) => {
if (password === null) {
authState.setState('auth-failed', 'Authentication cancelled')
callback(false as never)
return
}
callback({
type: 'password' as const,
username: target.username,
password
} as never)
})
.catch(() => {
callback(false as never)
})
return
}
authState.setState('auth-failed', 'No supported authentication methods')
callback(false as never)
}
}
// If an identity file is specified, try it for the initial attempt
if (target.identityFile) {
export function findDefaultKeyFile(): { path: string; contents: Buffer } | undefined {
for (const keyPath of DEFAULT_KEY_PATHS) {
const resolved = keyPath.replace(/^~/, homedir())
try {
config.privateKey = readFileSync(target.identityFile)
if (!existsSync(resolved)) {
continue
}
const contents = readFileSync(resolved)
return { path: keyPath, contents }
} catch {
// Will fall through to other auth methods
continue
}
}
return undefined
}
// Try SSH agent by default
// Why: matches VS Code's _connectSSH auth method selection (lines 606-611, 727-758).
// ssh2 handles the auth negotiation natively — no custom authHandler needed.
export function buildConnectConfig(
target: SshTarget,
resolved: SshResolvedConfig | null
): ConnectConfig {
const effectiveHost = target.host || resolved?.hostname || target.label
const effectivePort = target.port || resolved?.port || 22
const effectiveUser = target.username || resolved?.user || ''
const config: Record<string, unknown> = {
host: effectiveHost,
port: effectivePort,
username: effectiveUser,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 15_000
}
// Why: always provide agent when available. Unlike VS Code (which has a
// passphrase prompt UI), we can't decrypt passphrase-protected keys at
// runtime. The agent holds decrypted keys, so it must always be a
// fallback even when an explicit key file is also provided.
if (process.env.SSH_AUTH_SOCK) {
config.agent = process.env.SSH_AUTH_SOCK
}
let proxyProcess: ChildProcess | null = null
if (target.proxyCommand) {
const { spawn } = await import('child_process')
const expanded = target.proxyCommand
.replace(/%h/g, shellEscape(target.host))
.replace(/%p/g, shellEscape(String(target.port)))
.replace(/%r/g, shellEscape(target.username))
proxyProcess = spawn('/bin/sh', ['-c', expanded], { stdio: ['pipe', 'pipe', 'pipe'] })
// Why: a single PassThrough used for both directions creates a feedback loop —
// proxy stdout data flows through the PassThrough and gets piped right back to
// proxy stdin. Use a Duplex wrapper where reads come from stdout and writes
// go to stdin independently.
const { Duplex } = await import('stream')
const stream = new Duplex({
read() {},
write(chunk, _encoding, cb) {
proxyProcess!.stdin!.write(chunk, cb)
}
})
proxyProcess.stdout!.on('data', (data) => stream.push(data))
proxyProcess.stdout!.on('end', () => stream.push(null))
config.sock = stream as unknown as NetSocket
const resolvedIdentity = resolved?.identityFile?.[0]
const explicitKey =
target.identityFile ||
(resolvedIdentity && !EXPANDED_DEFAULT_KEY_PATHS.includes(resolvedIdentity)
? resolvedIdentity
: undefined)
if (explicitKey) {
try {
config.privateKey = readFileSync(explicitKey.replace(/^~/, homedir()))
} catch {
// Key unreadable — agent will handle auth if available
}
} else {
const fallback = findDefaultKeyFile()
if (fallback) {
config.privateKey = fallback.contents
}
}
// Wire JumpHost: establish an intermediate SSH connection and forward a channel.
// Why: the jump client is returned to the caller so it can be destroyed on
// disconnect — otherwise the intermediate TCP connection leaks.
let jumpClient: SshClient | null = null
if (target.jumpHost && !target.proxyCommand) {
jumpClient = new SshClient()
const jumpConn = jumpClient
await new Promise<void>((resolve, reject) => {
jumpConn.on('ready', () => resolve())
jumpConn.on('error', (err) => reject(err))
jumpConn.connect({
host: target.jumpHost!,
port: 22,
username: target.username,
agent: process.env.SSH_AUTH_SOCK ?? undefined,
readyTimeout: CONNECT_TIMEOUT_MS
})
})
const forwardedChannel = await new Promise<ClientChannel>((resolve, reject) => {
jumpConn.forwardOut('127.0.0.1', 0, target.host, target.port, (err, channel) => {
if (err) {
reject(err)
} else {
resolve(channel)
}
})
})
config.sock = forwardedChannel as unknown as NetSocket
return config as ConnectConfig
}
// Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand.
// OpenSSH internally converts `ProxyJump bastion` to
// `ProxyCommand ssh -W %h:%p bastion`. We do the same so that ssh2
// gets a single proxy spawn path regardless of how the tunnel was configured.
export type EffectiveProxy =
| { kind: 'proxy-command'; command: string }
| { kind: 'jump-host'; jumpHost: string }
export function resolveEffectiveProxy(
target: SshTarget,
resolved: SshResolvedConfig | null
): EffectiveProxy | undefined {
if (target.proxyCommand) {
return { kind: 'proxy-command', command: target.proxyCommand }
}
if (resolved?.proxyCommand) {
return { kind: 'proxy-command', command: resolved.proxyCommand }
}
const jump = target.jumpHost || resolved?.proxyJump
if (jump) {
return { kind: 'jump-host', jumpHost: jump }
}
return undefined
}
// Why: ssh2 doesn't natively support ProxyCommand. When the SSH config
// specifies one (e.g. `cloudflared access ssh --hostname %h`), we spawn
// the command and bridge its stdin/stdout into a Duplex stream that ssh2
// uses as its transport socket via `config.sock`.
function getShellSpawnConfig(command: string): { file: string; args: string[] } {
if (process.platform === 'win32') {
const comspec = process.env.ComSpec || 'cmd.exe'
return { file: comspec, args: ['/d', '/s', '/c', command] }
}
return { file: '/bin/sh', args: ['-c', command] }
}
export function spawnProxyCommand(
proxy: EffectiveProxy,
host: string,
port: number,
user: string
): { process: ChildProcess; sock: NetSocket } {
const proc =
proxy.kind === 'jump-host'
? // Why: ProxyJump is structured input, not a shell snippet. Spawn ssh
// directly so jump-host values cannot escape through shell parsing.
spawn('ssh', ['-W', `${host}:${port}`, '--', proxy.jumpHost], { stdio: ['pipe', 'pipe', 'pipe'] })
: (() => {
const escape = process.platform === 'win32' ? cmdEscape : shellEscape
const expanded = proxy.command
.replace(/%h/g, escape(host))
.replace(/%p/g, escape(String(port)))
.replace(/%r/g, escape(user))
const shell = getShellSpawnConfig(expanded)
return spawn(shell.file, shell.args, { stdio: ['pipe', 'pipe', 'pipe'] })
})()
// Why: a single PassThrough for both directions creates a feedback loop.
// Reads come from the proxy's stdout; writes go to its stdin.
const stream = new Duplex({
read() {},
write(chunk, _encoding, cb) {
proc.stdin!.write(chunk, cb)
}
})
proc.stdout!.on('data', (data) => stream.push(data))
proc.stdout!.on('end', () => stream.push(null))
proc.stdin!.on('error', (err) => stream.destroy(err))
proc.on('error', (err) => stream.destroy(err))
return { config, jumpClient, proxyProcess }
return { process: proc, sock: stream as unknown as NetSocket }
}
+20 -3
View File
@@ -36,7 +36,12 @@ vi.mock('./ssh-system-fallback', () => ({
})
}))
vi.mock('./ssh-config-parser', () => ({
resolveWithSshG: vi.fn().mockResolvedValue(null)
}))
import { SshConnection, SshConnectionManager, type SshConnectionCallbacks } from './ssh-connection'
import { resolveWithSshG } from './ssh-config-parser'
import type { SshTarget } from '../../shared/ssh-types'
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
@@ -53,9 +58,6 @@ function createTarget(overrides?: Partial<SshTarget>): SshTarget {
function createCallbacks(overrides?: Partial<SshConnectionCallbacks>): SshConnectionCallbacks {
return {
onStateChange: vi.fn(),
onHostKeyVerify: vi.fn().mockResolvedValue(true),
onAuthChallenge: vi.fn().mockResolvedValue(['123456']),
onPasswordPrompt: vi.fn().mockResolvedValue('password'),
...overrides
}
}
@@ -138,6 +140,21 @@ describe('SshConnection', () => {
await expect(conn.connect()).rejects.toThrow('Connection disposed')
})
it('resolves OpenSSH config using configHost when present', async () => {
const callbacks = createCallbacks()
const conn = new SshConnection(
createTarget({
label: 'Friendly Name',
configHost: 'ssh-alias'
}),
callbacks
)
await conn.connect()
expect(resolveWithSshG).toHaveBeenCalledWith('ssh-alias')
})
})
describe('SshConnectionManager', () => {
+159 -164
View File
@@ -1,31 +1,28 @@
/* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */
import { Client as SshClient } from 'ssh2'
import type { ChildProcess } from 'child_process'
import type { ClientChannel, SFTPWrapper } from 'ssh2'
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2'
import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types'
import { spawnSystemSsh, type SystemSshProcess } from './ssh-system-fallback'
import { resolveWithSshG } from './ssh-config-parser'
import {
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS,
CONNECT_TIMEOUT_MS,
isTransientError,
isAuthError,
isPassphraseError,
sleep,
buildConnectConfig,
resolveEffectiveProxy,
spawnProxyCommand,
type SshConnectionCallbacks
} from './ssh-connection-utils'
// Why: type definitions live in ssh-connection-utils.ts to break a circular
// import. Re-exported here so existing import sites keep working.
export type {
HostKeyVerifyRequest,
AuthChallengeRequest,
SshConnectionCallbacks
} from './ssh-connection-utils'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
export class SshConnection {
private client: SshClient | null = null
/** Why: the jump host client must be tracked so it can be torn down on
* disconnect — otherwise the intermediate TCP connection leaks. */
private jumpClient: SshClient | null = null
private proxyProcess: ChildProcess | null = null
private systemSsh: SystemSshProcess | null = null
private state: SshConnectionState
@@ -33,8 +30,9 @@ export class SshConnection {
private target: SshTarget
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private disposed = false
private agentAttempted = false
private keyAttempted = false
private cachedPassphrase: string | null = null
private cachedPassword: string | null = null
private connectGeneration = 0
constructor(target: SshTarget, callbacks: SshConnectionCallbacks) {
this.target = target
@@ -50,47 +48,25 @@ export class SshConnection {
getState(): SshConnectionState {
return { ...this.state }
}
getClient(): SshClient | null {
return this.client
}
getTarget(): SshTarget {
return { ...this.target }
}
/** Open an exec channel. Used by relay deployment to run commands on the remote. */
async exec(command: string): Promise<ClientChannel> {
const client = this.client
if (!client) {
async exec(cmd: string): Promise<ClientChannel> {
if (!this.client) {
throw new Error('Not connected')
}
return new Promise((resolve, reject) => {
client.exec(command, (err, channel) => {
if (err) {
reject(err)
} else {
resolve(channel)
}
})
})
return new Promise((res, rej) => this.client!.exec(cmd, (e, ch) => (e ? rej(e) : res(ch))))
}
/** Open an SFTP session for file transfers (relay deployment). */
async sftp(): Promise<SFTPWrapper> {
const client = this.client
if (!client) {
if (!this.client) {
throw new Error('Not connected')
}
return new Promise((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(err)
} else {
resolve(sftp)
}
})
})
return new Promise((res, rej) => this.client!.sftp((e, s) => (e ? rej(e) : res(s))))
}
async connect(): Promise<void> {
@@ -107,7 +83,13 @@ export class SshConnection {
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
if (isAuthError(lastError) || isPassphraseError(lastError)) {
this.setState('auth-failed', lastError.message)
throw lastError
}
if (!isTransientError(lastError)) {
this.setState('error', lastError.message)
throw lastError
}
@@ -124,95 +106,136 @@ export class SshConnection {
private async attemptConnect(): Promise<void> {
this.setState('connecting')
this.agentAttempted = false
this.keyAttempted = false
this.proxyProcess?.kill()
this.proxyProcess = null
const connectGeneration = ++this.connectGeneration
// Why: clean up resources from a prior failed attempt before overwriting.
// Without this, a retry after timeout/auth-failure orphans the old jump
// host TCP connection and proxy child process.
if (this.jumpClient) {
this.jumpClient.end()
this.jumpClient = null
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(() => null)
const config = buildConnectConfig(this.target, resolved)
// Why: ssh2 doesn't support ProxyCommand/ProxyJump natively. Spawn the
// resolved proxy and pipe its stdin/stdout as config.sock.
const effectiveProxy = resolveEffectiveProxy(this.target, resolved)
if (effectiveProxy) {
const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!)
this.proxyProcess = proxy.process
config.sock = proxy.sock
}
if (this.proxyProcess) {
this.proxyProcess.kill()
if (this.cachedPassphrase) {
config.passphrase = this.cachedPassphrase
}
if (this.cachedPassword) {
config.password = this.cachedPassword
}
try {
await this.doSsh2Connect(config, connectGeneration)
} catch (err) {
if (!(err instanceof Error) || !this.callbacks.onCredentialRequest) {
this.proxyProcess?.kill()
this.proxyProcess = null
throw err
}
// Why: prompt for passphrase on encrypted-key error, then retry with
// a fresh proxy socket (ssh2 may have destroyed the original).
if (isPassphraseError(err) && !this.cachedPassphrase) {
const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)'
const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail)
if (val) {
this.cachedPassphrase = val
config.passphrase = val
this.respawnProxy(config, effectiveProxy)
await this.doSsh2Connect(config, connectGeneration)
return
}
}
// Why: prompt for password on auth failure. Check the original error
// (not a retry error) to avoid conflating passphrase vs password failures.
if (isAuthError(err) && !this.cachedPassword) {
const val = await this.callbacks.onCredentialRequest(
this.target.id,
'password',
config.host || this.target.label
)
if (val) {
this.cachedPassword = val
config.password = val
this.respawnProxy(config, effectiveProxy)
await this.doSsh2Connect(config, connectGeneration)
return
}
}
this.proxyProcess?.kill()
this.proxyProcess = null
throw err
}
}
const { config, jumpClient, proxyProcess } = await this.buildConfig()
this.jumpClient = jumpClient
this.proxyProcess = proxyProcess
// Why: ssh2 may destroy the proxy socket on auth failure, so credential
// retries need a fresh proxy process and Duplex stream.
private respawnProxy(
config: ConnectConfig,
proxy: ReturnType<typeof resolveEffectiveProxy> | null | undefined
): void {
if (!proxy) {
return
}
this.proxyProcess?.kill()
const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!)
this.proxyProcess = p.process
config.sock = p.sock
}
private doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const client = new SshClient()
let settled = false
const timeout = setTimeout(() => {
if (!settled) {
settled = true
client.destroy()
const msg = `Connection timed out after ${CONNECT_TIMEOUT_MS}ms`
this.setState('error', msg)
reject(new Error(msg))
}
}, CONNECT_TIMEOUT_MS)
// Why: host key verification is now handled inside the hostVerifier
// callback in buildConnectConfig (ssh-connection-utils.ts). The
// callback form `(key, verify) => void` blocks the handshake until
// the user accepts/rejects, so no separate 'handshake' listener is
// needed here.
client.on('ready', () => {
if (settled) {
return
}
// Why: connect() completion races with explicit disconnect(). Once a
// newer connect attempt or disconnect bumps the generation/disposed
// state, this late ready event must not resurrect the torn-down client.
if (this.disposed || connectGeneration !== this.connectGeneration) {
settled = true
client.end()
client.destroy()
reject(new Error('SSH connection attempt was cancelled'))
return
}
settled = true
clearTimeout(timeout)
this.client = client
this.proxyProcess = null
this.setState('connected')
this.setupDisconnectHandler(client)
resolve()
})
client.on('error', (err) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
this.setState('error', err.message)
client.destroy()
reject(err)
})
client.connect(config)
})
}
private async buildConfig() {
// Why: config-building logic extracted to ssh-connection-utils.ts (max-lines).
return buildConnectConfig(this.target, this.callbacks, {
agentAttempted: this.agentAttempted,
keyAttempted: this.keyAttempted,
setState: (status: string, error?: string) => {
this.setState(status as SshConnectionStatus, error)
}
})
}
// Why: both `end` and `close` fire on disconnect. If reconnect succeeds
// between the two events, the second handler would null out the *new*
// connection. Guarding on `this.client === client` prevents that.
// Why: guard on identity so a late event from the old client doesn't
// null out a successful reconnect.
private setupDisconnectHandler(client: SshClient): void {
const handleDisconnect = () => {
const onDrop = () => {
if (this.disposed || this.client !== client) {
return
}
this.client = null
this.scheduleReconnect()
}
client.on('end', handleDisconnect)
client.on('close', handleDisconnect)
client.on('end', onDrop)
client.on('close', onDrop)
client.on('error', (err) => {
if (this.disposed || this.client !== client) {
return
@@ -227,71 +250,58 @@ export class SshConnection {
if (this.disposed || this.reconnectTimer) {
return
}
const attempt = this.state.reconnectAttempt
if (attempt >= RECONNECT_BACKOFF_MS.length) {
this.setState('reconnection-failed', 'Max reconnection attempts reached')
return
}
this.setState('reconnecting')
const delay = RECONNECT_BACKOFF_MS[attempt]
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null
if (this.disposed) {
return
}
try {
await this.attemptConnect()
// Why: reset the counter and re-broadcast so the UI shows attempt 0.
// attemptConnect already calls setState('connected'), but the attempt
// counter must be zeroed *before* so the broadcast carries the right value.
// Why: reset reconnectAttempt before attemptConnect so setState('connected')
// broadcasts reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment.
this.state.reconnectAttempt = 0
this.setState('connected')
} catch {
// Why: increment before scheduleReconnect so the setState('reconnecting')
// call inside it broadcasts the updated attempt number to the UI.
this.state.reconnectAttempt++
await this.attemptConnect()
} catch (err) {
if (this.disposed) {
return
}
const error = err instanceof Error ? err : new Error(String(err))
if (isAuthError(error) || isPassphraseError(error)) {
this.setState('auth-failed', error.message)
return
}
if (!isTransientError(error)) {
this.setState('error', error.message)
return
}
this.state.reconnectAttempt = attempt + 1
this.scheduleReconnect()
}
}, delay)
}, RECONNECT_BACKOFF_MS[attempt])
}
/** Fall back to system SSH binary when ssh2 cannot handle auth (FIDO2, ControlMaster). */
async connectViaSystemSsh(): Promise<SystemSshProcess> {
if (this.disposed) {
throw new Error('Connection disposed')
}
// Why: if connectViaSystemSsh is called again after a prior failed attempt,
// the old process may still be running. Without cleanup, overwriting
// this.systemSsh at line 267 would orphan the old process.
if (this.systemSsh) {
this.systemSsh.kill()
this.systemSsh = null
}
this.systemSsh?.kill()
this.systemSsh = null
this.setState('connecting')
try {
const proc = spawnSystemSsh(this.target)
this.systemSsh = proc
// Why: two onExit handlers are registered — one for the initial handshake
// (reject the promise on early exit) and one for post-connect reconnection.
// Without a settled flag, an early exit during handshake would fire both,
// causing the reconnection handler to schedule a reconnect for a connection
// that was never established.
let settled = false
// Why: verify the SSH connection succeeded before reporting connected.
// Wait for relay sentinel output or a non-zero exit.
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
settled = true
proc.kill()
reject(new Error('System SSH connection timed out'))
}, CONNECT_TIMEOUT_MS)
proc.stdout.once('data', () => {
settled = true
clearTimeout(timeout)
@@ -303,69 +313,54 @@ export class SshConnection {
}
settled = true
clearTimeout(timeout)
if (code !== 0) {
reject(new Error(`System SSH exited with code ${code}`))
}
reject(
new Error(
code !== 0
? `System SSH exited with code ${code}`
: 'System SSH exited before producing output'
)
)
})
})
this.setState('connected')
// Why: unlike ssh2 Client which emits end/close, the system SSH process
// only signals disconnection through its exit event. Without this handler
// an unexpected exit would leave the connection in 'connected' state with
// no underlying transport.
proc.onExit((_code) => {
// Why: register reconnection handler only after the initial handshake
// succeeds. The onExit registered above guards with `settled` so it
// won't fire a duplicate for exits during the handshake phase.
proc.onExit(() => {
if (!this.disposed && this.systemSsh === proc) {
this.systemSsh = null
this.scheduleReconnect()
}
})
return proc
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.setState('error', msg)
this.setState('error', err instanceof Error ? err.message : String(err))
throw err
}
}
async disconnect(): Promise<void> {
this.disposed = true
this.connectGeneration += 1
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.client) {
this.client.end()
this.client = null
}
// Why: the jump host client holds an open TCP connection to the
// intermediate host. Failing to close it would leak the socket.
if (this.jumpClient) {
this.jumpClient.end()
this.jumpClient = null
}
if (this.proxyProcess) {
this.proxyProcess.kill()
this.proxyProcess = null
}
if (this.systemSsh) {
this.systemSsh.kill()
this.systemSsh = null
}
this.reconnectTimer = null
this.cachedPassphrase = null
this.cachedPassword = null
this.client?.end()
this.client = null
this.proxyProcess?.kill()
this.proxyProcess = null
this.systemSsh?.kill()
this.systemSsh = null
this.setState('disconnected')
}
private setState(status: SshConnectionStatus, error?: string): void {
this.state = {
...this.state,
status,
error: error ?? null
}
this.state = { ...this.state, status, error: error ?? null }
this.callbacks.onStateChange(this.target.id, { ...this.state })
}
}
// Why: extracted to ssh-connection-manager.ts to stay under 300-line max-lines.
export { SshConnectionManager } from './ssh-connection-manager'
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn()
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn().mockResolvedValue(undefined),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn().mockResolvedValue('Linux x86_64'),
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
function makeMockConnection(): SshConnection {
return {
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
end: vi.fn()
}),
end: vi.fn()
})
} as unknown as SshConnection
}
describe('deployAndLaunchRelay', () => {
it('calls exec to detect remote platform', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm
mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME
mockExecCommand.mockResolvedValueOnce('OK') // check relay exists
mockExecCommand.mockResolvedValueOnce('0.1.0') // version check
await deployAndLaunchRelay(conn)
expect(mockExecCommand).toHaveBeenCalledWith(conn, 'uname -sm')
})
it('reports progress via callback', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
mockExecCommand.mockResolvedValueOnce('/home/user')
mockExecCommand.mockResolvedValueOnce('OK')
mockExecCommand.mockResolvedValueOnce('0.1.0')
const progress: string[] = []
await deployAndLaunchRelay(conn, (status) => progress.push(status))
expect(progress).toContain('Detecting remote platform...')
expect(progress).toContain('Starting relay...')
})
it('has a 120-second overall timeout', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
// Make the first exec never resolve
mockExecCommand.mockReturnValueOnce(new Promise(() => {}))
vi.useFakeTimers()
// Catch the rejection immediately to avoid unhandled rejection warning
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(121_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 120s')
vi.useRealTimers()
})
})
+29 -3
View File
@@ -22,6 +22,12 @@ export type RelayDeployResult = {
platform: RelayPlatform
}
// Why: individual exec commands have 30s timeouts, but the full deploy
// pipeline (detect platform → check existing → upload → npm install →
// launch) has no overall bound. A hanging `npm install` or slow SFTP
// upload could block the connection indefinitely.
const RELAY_DEPLOY_TIMEOUT_MS = 120_000
/**
* Deploy the relay to the remote host and launch it.
*
@@ -36,6 +42,24 @@ export type RelayDeployResult = {
export async function deployAndLaunchRelay(
conn: SshConnection,
onProgress?: (status: string) => void
): Promise<RelayDeployResult> {
let timeoutHandle: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`))
}, RELAY_DEPLOY_TIMEOUT_MS)
})
try {
return await Promise.race([deployAndLaunchRelayInner(conn, onProgress), timeoutPromise])
} finally {
clearTimeout(timeoutHandle!)
}
}
async function deployAndLaunchRelayInner(
conn: SshConnection,
onProgress?: (status: string) => void
): Promise<RelayDeployResult> {
onProgress?.('Detecting remote platform...')
console.log('[ssh-relay] Detecting remote platform...')
@@ -50,9 +74,11 @@ export async function deployAndLaunchRelay(
// Why: SFTP does not expand `~`, so we must resolve the remote home directory
// explicitly. `echo $HOME` over exec gives us the absolute path.
const remoteHome = (await execCommand(conn, 'echo $HOME')).trim()
// Why: a malicious or misconfigured remote could return a $HOME containing
// shell metacharacters. Validate it looks like a reasonable path.
if (!remoteHome || !/^\/[a-zA-Z0-9/_.-]+$/.test(remoteHome)) {
// Why: we only interpolate $HOME into single-quoted shell strings later, so
// this validation only needs to reject obviously unsafe control characters.
// Allow spaces and non-ASCII so valid home directories are not rejected.
// oxlint-disable-next-line no-control-regex
if (!remoteHome || !remoteHome.startsWith('/') || /[\u0000\r\n]/.test(remoteHome)) {
throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`)
}
const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}`
+1 -1
View File
@@ -76,7 +76,7 @@ function buildSshArgs(target: SshTarget): string[] {
}
const userHost = target.username ? `${target.username}@${target.host}` : target.host
args.push(userHost)
args.push('--', userHost)
return args
}