mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
fix: harden SSH connection reliability and add passphrase prompt (#636)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user