diff --git a/src/main/core/ssh/build-connect-config.test.ts b/src/main/core/ssh/build-connect-config.test.ts new file mode 100644 index 000000000..6a26fb1db --- /dev/null +++ b/src/main/core/ssh/build-connect-config.test.ts @@ -0,0 +1,107 @@ +import { PassThrough } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SshConnectionRow } from '@main/db/schema'; +import { buildConnectConfigFromRow } from './build-connect-config'; + +const mocks = vi.hoisted(() => ({ + readFileMock: vi.fn(), + resolveSshConfigHostMock: vi.fn(), + getPasswordMock: vi.fn(), + getPassphraseMock: vi.fn(), + buildProxyJumpSocketMock: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: mocks.readFileMock, +})); + +vi.mock('@main/core/ssh/sshConfigParser', () => ({ + resolveSshConfigHost: mocks.resolveSshConfigHostMock, +})); + +vi.mock('@main/core/ssh/ssh-credential-service', () => ({ + sshCredentialService: { + getPassword: mocks.getPasswordMock, + getPassphrase: mocks.getPassphraseMock, + }, +})); + +vi.mock('./proxy-jump-sock', () => ({ + buildProxyJumpSocket: mocks.buildProxyJumpSocketMock, +})); + +function makeRow(partial: Partial): SshConnectionRow { + return { + id: 'conn-1', + name: 'Conn', + host: 'example', + port: 22, + username: 'ubuntu', + authType: 'agent', + privateKeyPath: null, + useAgent: 1, + metadata: null, + createdAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T00:00:00.000Z', + ...partial, + }; +} + +describe('buildConnectConfigFromRow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveSshConfigHostMock.mockResolvedValue(undefined); + }); + + it('does not spawn ProxyJump socket when key loading fails', async () => { + mocks.resolveSshConfigHostMock.mockResolvedValue({ + host: 'alias', + hostname: '10.0.0.55', + proxyJump: 'bastion', + }); + mocks.readFileMock.mockRejectedValue(new Error('ENOENT: no such file or directory')); + + await expect( + buildConnectConfigFromRow( + makeRow({ + authType: 'key', + privateKeyPath: '/missing/id_ed25519', + }) + ) + ).rejects.toThrow('ENOENT'); + + expect(mocks.buildProxyJumpSocketMock).not.toHaveBeenCalled(); + }); + + it('attaches ProxyJump socket after auth config succeeds', async () => { + const sock = new PassThrough(); + mocks.resolveSshConfigHostMock.mockResolvedValue({ + host: 'alias', + hostname: '10.0.0.55', + port: 2202, + user: 'studio', + proxyJump: 'jumpuser@bastion:2200', + }); + mocks.getPasswordMock.mockResolvedValue('secret'); + mocks.buildProxyJumpSocketMock.mockReturnValue(sock); + + const config = await buildConnectConfigFromRow( + makeRow({ + authType: 'password', + }) + ); + + expect(config).toMatchObject({ + host: '10.0.0.55', + port: 2202, + username: 'studio', + password: 'secret', + }); + expect(mocks.buildProxyJumpSocketMock).toHaveBeenCalledWith( + '10.0.0.55', + 2202, + 'jumpuser@bastion:2200' + ); + expect(config?.sock).toBe(sock); + }); +}); diff --git a/src/main/core/ssh/build-connect-config.ts b/src/main/core/ssh/build-connect-config.ts index aa8ef52d7..395d0476e 100644 --- a/src/main/core/ssh/build-connect-config.ts +++ b/src/main/core/ssh/build-connect-config.ts @@ -2,8 +2,9 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import type { ConnectConfig } from 'ssh2'; import { sshCredentialService } from '@main/core/ssh/ssh-credential-service'; -import { resolveIdentityAgent } from '@main/core/ssh/sshConfigParser'; +import { resolveSshConfigHost } from '@main/core/ssh/sshConfigParser'; import type { SshConnectionRow } from '@main/db/schema'; +import { buildProxyJumpSocket } from './proxy-jump-sock'; /** * Build an ssh2 `ConnectConfig` from a stored `SshConnectionRow`. @@ -11,14 +12,22 @@ import type { SshConnectionRow } from '@main/db/schema'; export async function buildConnectConfigFromRow( row: SshConnectionRow ): Promise { + const configHost = await resolveSshConfigHost(row.host); + const targetHost = configHost?.hostname ?? row.host; + const targetPort = configHost?.port ?? row.port; + const targetUsername = configHost?.user ?? row.username; + const identityAgent = configHost?.identityAgent; + const proxyJump = configHost?.proxyJump; + const base: ConnectConfig = { - host: row.host, - port: row.port, - username: row.username, + host: targetHost, + port: targetPort, + username: targetUsername, readyTimeout: 20_000, keepaliveInterval: 60_000, keepaliveCountMax: 3, }; + const authConfig: Partial = {}; switch (row.authType) { case 'password': { @@ -26,7 +35,8 @@ export async function buildConnectConfigFromRow( if (!password) { throw new Error(`No password found for SSH connection '${row.name}' (id: ${row.id})`); } - return { ...base, password }; + authConfig.password = password; + break; } case 'key': { @@ -41,11 +51,14 @@ export async function buildConnectConfigFromRow( } const privateKey = await readFile(keyPath, 'utf-8'); const passphrase = await sshCredentialService.getPassphrase(row.id); - return { ...base, privateKey, ...(passphrase ? { passphrase } : {}) }; + authConfig.privateKey = privateKey; + if (passphrase) { + authConfig.passphrase = passphrase; + } + break; } case 'agent': { - const identityAgent = await resolveIdentityAgent(row.host); const agent = identityAgent || process.env.SSH_AUTH_SOCK; if (!agent) { throw new Error( @@ -53,11 +66,18 @@ export async function buildConnectConfigFromRow( 'Ensure the SSH agent is running or use key/password auth.' ); } - return { ...base, agent }; + authConfig.agent = agent; + break; } default: { throw new Error(`Unsupported SSH auth type: ${(row as { authType: string }).authType}`); } } + + const config: ConnectConfig = { ...base, ...authConfig }; + if (proxyJump) { + config.sock = buildProxyJumpSocket(targetHost, targetPort, proxyJump); + } + return config; } diff --git a/src/main/core/ssh/controller.test.ts b/src/main/core/ssh/controller.test.ts new file mode 100644 index 000000000..c512a518b --- /dev/null +++ b/src/main/core/ssh/controller.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sshController } from './controller'; + +const mocks = vi.hoisted(() => { + const state = { + captureMock: vi.fn(), + proxyDestroyMock: vi.fn(), + resolveSshConfigHostMock: vi.fn(), + clientConnectMock: vi.fn(), + clientEndMock: vi.fn(), + clientInstances: [] as Array<{ + emit: (event: string, ...args: unknown[]) => boolean; + }>, + }; + + function TestClient(this: { + connect?: typeof state.clientConnectMock; + end?: typeof state.clientEndMock; + on?: (event: string, listener: (...args: unknown[]) => void) => unknown; + emit?: (event: string, ...args: unknown[]) => boolean; + }) { + const listeners = new Map void>>(); + + this.connect = state.clientConnectMock; + this.end = state.clientEndMock; + this.on = (event: string, listener: (...args: unknown[]) => void) => { + const list = listeners.get(event) ?? []; + list.push(listener); + listeners.set(event, list); + return this; + }; + this.emit = (event: string, ...args: unknown[]) => { + const registered = listeners.get(event) ?? []; + for (const listener of registered) { + listener(...args); + } + return registered.length > 0; + }; + state.clientInstances.push(this as { emit: (event: string, ...args: unknown[]) => boolean }); + } + + return { + ...state, + TestClient, + }; +}); + +vi.mock('ssh2', () => ({ + Client: mocks.TestClient, +})); + +vi.mock('@main/core/ssh/proxy-jump-sock', () => ({ + buildProxyJumpSocket: vi.fn(() => ({ + destroy: mocks.proxyDestroyMock, + })), +})); + +vi.mock('@main/core/ssh/sshConfigParser', () => ({ + resolveSshConfigHost: mocks.resolveSshConfigHostMock, +})); + +vi.mock('@main/lib/telemetry', () => ({ + capture: mocks.captureMock, +})); + +vi.mock('@main/core/ssh/ssh-connection-manager', () => ({ + sshConnectionManager: { + isConnected: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), + getConnectionState: vi.fn(), + getAllConnectionStates: vi.fn(), + getProxy: vi.fn(), + }, +})); + +vi.mock('@main/core/ssh/sshCredentialService', () => ({ + sshCredentialService: { + storePassword: vi.fn(), + storePassphrase: vi.fn(), + deleteAllCredentials: vi.fn(), + }, +})); + +vi.mock('@main/db/client', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('@main/db/schema', () => ({ + sshConnections: { + id: 'sshConnections.id', + }, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { + warn: vi.fn(), + }, +})); + +describe('sshController.testConnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.clientInstances.length = 0; + mocks.resolveSshConfigHostMock.mockResolvedValue({ + hostname: 'target.internal', + port: 22, + user: 'ubuntu', + proxyJump: 'bastion.example.com', + }); + }); + + it('destroys the ProxyJump socket when ssh2 emits an error', async () => { + const resultPromise = sshController.testConnection({ + id: 'connection-id', + name: 'Connection', + host: 'host-alias', + port: 22, + username: 'ubuntu', + authType: 'password', + password: 'secret', + useAgent: false, + }); + + await Promise.resolve(); + + const client = mocks.clientInstances[0]; + if (!client) { + throw new Error('Expected SSH client to be constructed'); + } + + client.emit('error', new Error('permission denied')); + + await expect(resultPromise).resolves.toEqual({ + success: false, + error: 'permission denied', + debugLogs: expect.any(Array), + }); + expect(mocks.proxyDestroyMock).toHaveBeenCalledTimes(1); + expect(mocks.captureMock).toHaveBeenCalledWith('ssh_connection_attempted', { + success: false, + }); + }); +}); diff --git a/src/main/core/ssh/controller.ts b/src/main/core/ssh/controller.ts index 7cd06d824..66fe6340b 100644 --- a/src/main/core/ssh/controller.ts +++ b/src/main/core/ssh/controller.ts @@ -8,10 +8,12 @@ import type { ConnectionState, ConnectionTestResult, FileEntry, SshConfig } from import { db } from '@main/db/client'; import { sshConnections as sshConnectionsTable, type SshConnectionInsert } from '@main/db/schema'; import { log } from '@main/lib/logger'; +import { capture } from '@main/lib/telemetry'; +import { buildProxyJumpSocket } from './proxy-jump-sock'; import { telemetryService } from '@main/lib/telemetry'; import { sshConnectionManager } from './ssh-connection-manager'; import { sshCredentialService } from './ssh-credential-service'; -import { resolveIdentityAgent } from './utils'; +import { resolveSshConfigHost } from './sshConfigParser'; export const sshController = createRPCController({ /** List all saved SSH connections (no secrets). */ @@ -103,15 +105,18 @@ export const sshController = createRPCController({ testConnection: async ( config: SshConfig & { password?: string; passphrase?: string } ): Promise => { - let identityAgent: string | undefined; - if (config.authType === 'agent') { - identityAgent = await resolveIdentityAgent(config.host); - } + const configHost = await resolveSshConfigHost(config.host); + const targetHost = configHost?.hostname ?? config.host; + const targetPort = configHost?.port ?? config.port; + const targetUsername = configHost?.user ?? config.username; + const identityAgent = configHost?.identityAgent; + const proxyJump = configHost?.proxyJump; return new Promise((resolve) => { const client = new Client(); const debugLogs: string[] = []; const startTime = Date.now(); + let proxySock: ReturnType | undefined; client.on('ready', () => { const latency = Date.now() - startTime; @@ -127,9 +132,9 @@ export const sshController = createRPCController({ try { const connectConfig: Parameters[0] = { - host: config.host, - port: config.port, - username: config.username, + host: targetHost, + port: targetPort, + username: targetUsername, readyTimeout: 10_000, debug: (info: string) => debugLogs.push(info), }; @@ -144,9 +149,20 @@ export const sshController = createRPCController({ } else if (config.authType === 'agent') { connectConfig.agent = identityAgent || process.env.SSH_AUTH_SOCK; } + if (proxyJump) { + proxySock = buildProxyJumpSocket(targetHost, targetPort, proxyJump, { + onStderrLine: (line) => debugLogs.push(`[ProxyJump] ${line}`), + }); + connectConfig.sock = proxySock; + debugLogs.push(`Using ProxyJump via ${proxyJump}`); + } client.connect(connectConfig); } catch (e) { + // If local setup failed after we spawned the ProxyJump subprocess, + // ensure its stream/process is torn down immediately. + proxySock?.destroy(); + capture('ssh_connection_attempted', { success: false }); telemetryService.capture('ssh_connection_attempted', { success: false }); resolve({ success: false, error: (e as Error).message, debugLogs }); } diff --git a/src/main/core/ssh/proxy-jump-sock.test.ts b/src/main/core/ssh/proxy-jump-sock.test.ts new file mode 100644 index 000000000..e8e5bea5b --- /dev/null +++ b/src/main/core/ssh/proxy-jump-sock.test.ts @@ -0,0 +1,138 @@ +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildProxyJumpSocket } from './proxy-jump-sock'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +type MockChild = EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + killed: boolean; + kill: ReturnType; +}; + +function makeMockChild(): MockChild { + const child = new EventEmitter() as MockChild; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.killed = false; + child.kill = vi.fn(() => { + child.killed = true; + return true; + }); + return child; +} + +describe('buildProxyJumpSocket', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('spawns ssh with -W target and parsed jump port', () => { + const child = makeMockChild(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + + buildProxyJumpSocket('target.internal', 2202, 'jumpuser@bastion.example.com:2200'); + + expect(spawn).toHaveBeenCalledWith( + 'ssh', + [ + '-o', + 'BatchMode=yes', + '-o', + 'ControlMaster=no', + '-o', + 'ControlPath=none', + '-W', + 'target.internal:2202', + '-p', + '2200', + 'jumpuser@bastion.example.com', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + }); + + it('destroys socket with stderr details when proxy command exits non-zero', async () => { + const child = makeMockChild(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + + const socket = buildProxyJumpSocket('target.internal', 22, 'bastion'); + const errorPromise = new Promise((resolve) => { + socket.once('error', (error) => resolve(error as Error)); + }); + + child.stderr.write('Permission denied (publickey)\n'); + child.emit('exit', 255, null); + + const error = await errorPromise; + expect(error.message).toContain('ProxyJump command failed (exit code 255)'); + expect(error.message).toContain('Permission denied (publickey)'); + }); + + it('passes earlier hops via -J and uses the final hop as the destination', () => { + const child = makeMockChild(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + + buildProxyJumpSocket( + 'target.internal', + 22, + 'alice@hop1.example.com:2201, hop2.example.com , bob@hop3.example.com:2203' + ); + + expect(spawn).toHaveBeenCalledWith( + 'ssh', + [ + '-o', + 'BatchMode=yes', + '-o', + 'ControlMaster=no', + '-o', + 'ControlPath=none', + '-J', + 'alice@hop1.example.com:2201,hop2.example.com', + '-W', + 'target.internal:22', + '-p', + '2203', + 'bob@hop3.example.com', + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ); + }); + + it('reports "unknown exit" when child exits with neither code nor signal', async () => { + const child = makeMockChild(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + + const socket = buildProxyJumpSocket('target.internal', 22, 'bastion'); + const errorPromise = new Promise((resolve) => { + socket.once('error', (error) => resolve(error as Error)); + }); + + child.emit('exit', null, null); + + const error = await errorPromise; + expect(error.message).toContain('ProxyJump command failed (unknown exit)'); + expect(error.message).not.toContain('null'); + }); + + it('forwards stderr lines through onStderrLine callback', () => { + const child = makeMockChild(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const stderrLines: string[] = []; + + buildProxyJumpSocket('target.internal', 22, 'bastion', { + onStderrLine: (line) => stderrLines.push(line), + }); + + child.stderr.write('first line\nsecond line\n'); + expect(stderrLines).toEqual(['first line', 'second line']); + }); +}); diff --git a/src/main/core/ssh/proxy-jump-sock.ts b/src/main/core/ssh/proxy-jump-sock.ts new file mode 100644 index 000000000..a9a274ea2 --- /dev/null +++ b/src/main/core/ssh/proxy-jump-sock.ts @@ -0,0 +1,164 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { Duplex, type Readable, type Writable } from 'node:stream'; + +type SshChild = ChildProcessByStdio; + +/** + * Duplex wrapper around a child process's stdin/stdout that behaves like a + * net.Socket: writes are forwarded atomically (preserving SSH packet framing), + * reads are pushed in order, and lifecycle is tied to the child process. + * + * `Duplex.from({writable, readable})` is unsuitable here because it composes + * two independent streams without enforcing write ordering or proper + * backpressure, which corrupts ssh2's transport framing under concurrent + * channel opens. + */ +class ProcessSocket extends Duplex { + private readonly child: SshChild; + private endedReadable = false; + + constructor(child: SshChild) { + super({ allowHalfOpen: true }); + this.child = child; + + child.stdout.on('data', (chunk: Buffer) => { + if (!this.push(chunk)) { + child.stdout.pause(); + } + }); + child.stdout.once('end', () => this.endReadable()); + child.stdout.once('error', (err) => this.destroy(err)); + child.stdin.on('error', (err) => this.destroy(err)); + } + + private endReadable(): void { + if (this.endedReadable) return; + this.endedReadable = true; + this.push(null); + } + + override _read(): void { + this.child.stdout.resume(); + } + + override _write(chunk: Buffer, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + this.child.stdin.write(chunk, (err) => cb(err ?? null)); + } + + override _final(cb: (err?: Error | null) => void): void { + this.child.stdin.end(cb); + } + + override _destroy(err: Error | null, cb: (err: Error | null) => void): void { + if (!this.child.killed && this.child.exitCode === null) { + this.child.kill(); + } + cb(err); + } +} + +function splitProxyJumpEntry(entry: string): { destination: string; port?: string } { + const hop = entry.trim(); + const uriMatch = hop.match(/^ssh:\/\/(?:(.+?)@)?(\[[^\]]+\]|[^:/?#]+)(?::(\d+))?$/i); + if (uriMatch) { + const user = uriMatch[1]; + const host = uriMatch[2]; + return { destination: user ? `${user}@${host}` : host, port: uriMatch[3] }; + } + + const ipv6Match = hop.match(/^(.*@)?(\[[^\]]+\])(?::(\d+))?$/); + if (ipv6Match) { + const user = ipv6Match[1]?.slice(0, -1); + const host = ipv6Match[2]; + return { destination: user ? `${user}@${host}` : host, port: ipv6Match[3] }; + } + + const atIdx = hop.lastIndexOf('@'); + const hostPort = atIdx >= 0 ? hop.slice(atIdx + 1) : hop; + const user = atIdx >= 0 ? hop.slice(0, atIdx) : ''; + const colonIdx = hostPort.lastIndexOf(':'); + + if (colonIdx > 0) { + const host = hostPort.slice(0, colonIdx); + const port = hostPort.slice(colonIdx + 1); + if (/^\d+$/.test(port)) { + return { destination: user ? `${user}@${host}` : host, port }; + } + } + + return { destination: hop }; +} + +function parseProxyJumpChain(proxyJump: string): { + intermediates: string[]; + final: { destination: string; port?: string }; +} { + const hops = proxyJump + .split(',') + .map((h) => h.trim()) + .filter((h) => h.length > 0); + const finalHop = hops[hops.length - 1] ?? ''; + return { + intermediates: hops.slice(0, -1), + final: splitProxyJumpEntry(finalHop), + }; +} + +export function buildProxyJumpSocket( + targetHost: string, + targetPort: number, + proxyJump: string, + options?: { onStderrLine?: (line: string) => void } +): Duplex { + const { intermediates, final } = parseProxyJumpChain(proxyJump); + const args = ['-o', 'BatchMode=yes', '-o', 'ControlMaster=no', '-o', 'ControlPath=none']; + if (intermediates.length > 0) { + // Chain through earlier hops with -J; the final hop is the SSH destination + // and is what -W tunnels stdio through to reach targetHost:targetPort. + args.push('-J', intermediates.join(',')); + } + args.push('-W', `${targetHost}:${targetPort}`); + if (final.port) { + args.push('-p', final.port); + } + args.push(final.destination); + + const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] }) as SshChild; + const sock = new ProcessSocket(child); + let stderrOutput = ''; + + child.once('error', (error) => { + sock.destroy(error); + }); + + child.stderr.setEncoding('utf-8'); + child.stderr.on('data', (chunk: string) => { + stderrOutput += chunk; + if (options?.onStderrLine) { + for (const line of chunk.split('\n')) { + const trimmed = line.trim(); + if (trimmed) { + options.onStderrLine(trimmed); + } + } + } + // Cap retained stderr to prevent unbounded growth if the process is noisy. + if (stderrOutput.length > 4096) { + stderrOutput = stderrOutput.slice(-4096); + } + }); + + child.once('exit', (code, signal) => { + if (sock.destroyed || code === 0) return; + const reason = signal + ? `signal ${signal}` + : code != null + ? `exit code ${code}` + : 'unknown exit'; + const stderr = stderrOutput.trim(); + const detail = stderr ? `: ${stderr}` : ''; + sock.destroy(new Error(`ProxyJump command failed (${reason})${detail}`)); + }); + + return sock; +} diff --git a/src/main/core/ssh/sshConfigParser.test.ts b/src/main/core/ssh/sshConfigParser.test.ts new file mode 100644 index 000000000..fbd09be5d --- /dev/null +++ b/src/main/core/ssh/sshConfigParser.test.ts @@ -0,0 +1,102 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { describe, expect, it, vi } from 'vitest'; +import { parseSshConfigFile, resolveSshConfigHost } from './sshConfigParser'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +describe('sshConfigParser', () => { + it('parses host entries including ProxyJump and ignores wildcard hosts', async () => { + vi.mocked(readFile).mockResolvedValueOnce(` +Host *.internal + ProxyJump ignored + +Host macstudio + HostName 10.0.0.55 + User studio + Port 2222 + IdentityAgent "~/.1password/agent.sock" + ProxyJump jumpuser@bastion.example.com:2200 +`); + + const hosts = await parseSshConfigFile(); + expect(hosts).toEqual([ + { + host: 'macstudio', + hostname: '10.0.0.55', + user: 'studio', + port: 2222, + identityAgent: `${homedir()}/.1password/agent.sock`, + proxyJump: 'jumpuser@bastion.example.com:2200', + }, + ]); + }); + + it('resolves by Host alias by default', async () => { + vi.mocked(readFile).mockResolvedValueOnce(` +Host macstudio + HostName 10.0.0.55 + ProxyJump bastion +`); + await expect(resolveSshConfigHost('macstudio')).resolves.toMatchObject({ + host: 'macstudio', + hostname: '10.0.0.55', + proxyJump: 'bastion', + }); + }); + + it('does not apply reverse HostName matching by default', async () => { + vi.mocked(readFile).mockResolvedValueOnce(` +Host macstudio + HostName 10.0.0.55 + ProxyJump bastion +`); + await expect(resolveSshConfigHost('10.0.0.55')).resolves.toBeUndefined(); + }); + + it('can resolve by HostName when explicitly allowed', async () => { + vi.mocked(readFile).mockResolvedValueOnce(` +Host macstudio + HostName 10.0.0.55 + ProxyJump bastion +`); + await expect( + resolveSshConfigHost('10.0.0.55', { allowHostNameMatch: true }) + ).resolves.toMatchObject({ + host: 'macstudio', + hostname: '10.0.0.55', + proxyJump: 'bastion', + }); + }); + + it('resolves hosts declared as multiple aliases in one Host line', async () => { + vi.mocked(readFile).mockResolvedValueOnce(` +Host macstudio mac-studio + HostName 100.110.42.38 + User bnjoroge + ProxyJump tailscalework-claude +`); + + await expect(resolveSshConfigHost('macstudio')).resolves.toMatchObject({ + host: 'macstudio', + hostname: '100.110.42.38', + user: 'bnjoroge', + proxyJump: 'tailscalework-claude', + }); + + vi.mocked(readFile).mockResolvedValueOnce(` +Host macstudio mac-studio + HostName 100.110.42.38 + User bnjoroge + ProxyJump tailscalework-claude +`); + await expect(resolveSshConfigHost('mac-studio')).resolves.toMatchObject({ + host: 'mac-studio', + hostname: '100.110.42.38', + user: 'bnjoroge', + proxyJump: 'tailscalework-claude', + }); + }); +}); diff --git a/src/main/core/ssh/sshConfigParser.ts b/src/main/core/ssh/sshConfigParser.ts index a05825aaa..fdadde756 100644 --- a/src/main/core/ssh/sshConfigParser.ts +++ b/src/main/core/ssh/sshConfigParser.ts @@ -39,7 +39,15 @@ export async function parseSshConfigFile(): Promise { const hosts: SshConfigHost[] = []; const lines = content.split('\n'); - let currentHost: SshConfigHost | null = null; + let currentHostAliases: string[] = []; + let currentHostConfig: Omit | null = null; + + const flushCurrentHostBlock = () => { + if (!currentHostConfig || currentHostAliases.length === 0) return; + for (const alias of currentHostAliases) { + hosts.push({ host: alias, ...currentHostConfig }); + } + }; for (const line of lines) { const trimmed = line.trim(); @@ -50,67 +58,100 @@ export async function parseSshConfigFile(): Promise { // Match Host directive const hostMatch = trimmed.match(/^Host\s+(.+)$/i); if (hostMatch) { - // Save previous host if exists - if (currentHost && currentHost.host) { - hosts.push(currentHost); - } - // Start new host entry - const hostPattern = hostMatch[1].trim(); - // Skip wildcard patterns - if (!hostPattern.includes('*') && !hostPattern.includes('?')) { - currentHost = { host: hostPattern }; + flushCurrentHostBlock(); + + const hostAliases = hostMatch[1] + .trim() + .split(/\s+/) + .map((host) => stripQuotes(host)) + .filter((host) => host && !host.includes('*') && !host.includes('?')); + + if (hostAliases.length > 0) { + currentHostAliases = hostAliases; + currentHostConfig = {}; } else { - currentHost = null; + currentHostAliases = []; + currentHostConfig = null; } continue; } // Match HostName const hostnameMatch = trimmed.match(/^HostName\s+(.+)$/i); - if (hostnameMatch && currentHost) { - currentHost.hostname = hostnameMatch[1].trim(); + if (hostnameMatch && currentHostConfig) { + currentHostConfig.hostname = hostnameMatch[1].trim(); continue; } // Match User const userMatch = trimmed.match(/^User\s+(.+)$/i); - if (userMatch && currentHost) { - currentHost.user = userMatch[1].trim(); + if (userMatch && currentHostConfig) { + currentHostConfig.user = userMatch[1].trim(); continue; } // Match Port const portMatch = trimmed.match(/^Port\s+(\d+)$/i); - if (portMatch && currentHost) { - currentHost.port = parseInt(portMatch[1], 10); + if (portMatch && currentHostConfig) { + currentHostConfig.port = parseInt(portMatch[1], 10); continue; } // Match IdentityFile const identityMatch = trimmed.match(/^IdentityFile\s+(.+)$/i); - if (identityMatch && currentHost) { + if (identityMatch && currentHostConfig) { const identityFile = expandTilde(stripQuotes(identityMatch[1].trim())); - currentHost.identityFile = identityFile; + currentHostConfig.identityFile = identityFile; continue; } // Match IdentityAgent const identityAgentMatch = trimmed.match(/^IdentityAgent\s+(.+)$/i); - if (identityAgentMatch && currentHost) { + if (identityAgentMatch && currentHostConfig) { const identityAgent = expandTilde(stripQuotes(identityAgentMatch[1].trim())); - currentHost.identityAgent = identityAgent; + currentHostConfig.identityAgent = identityAgent; continue; } - } - // Don't forget the last host - if (currentHost && currentHost.host) { - hosts.push(currentHost); + // Match ProxyJump + const proxyJumpMatch = trimmed.match(/^ProxyJump\s+(.+)$/i); + if (proxyJumpMatch && currentHostConfig) { + const proxyJump = stripQuotes(proxyJumpMatch[1].trim()); + if (proxyJump.toLowerCase() !== 'none') { + currentHostConfig.proxyJump = proxyJump; + } else { + currentHostConfig.proxyJump = undefined; + } + continue; + } } + // Don't forget the last host block + flushCurrentHostBlock(); + return hosts; } +export async function resolveSshConfigHost( + hostname: string, + options?: { allowHostNameMatch?: boolean } +): Promise { + try { + const hosts = await parseSshConfigFile(); + const query = hostname.toLowerCase(); + const aliasMatch = hosts.find((h) => h.host.toLowerCase() === query); + if (aliasMatch) { + return aliasMatch; + } + if (options?.allowHostNameMatch) { + return hosts.find((h) => h.hostname?.toLowerCase() === query); + } + return undefined; + } catch { + return undefined; + } +} + /** * Resolves the IdentityAgent socket path for a given hostname. * @@ -119,15 +160,6 @@ export async function parseSshConfigFile(): Promise { * IdentityAgent path if found, or undefined. */ export async function resolveIdentityAgent(hostname: string): Promise { - try { - const hosts = await parseSshConfigFile(); - const match = hosts.find( - (h) => - h.host.toLowerCase() === hostname.toLowerCase() || - h.hostname?.toLowerCase() === hostname.toLowerCase() - ); - return match?.identityAgent; - } catch { - return undefined; - } + const match = await resolveSshConfigHost(hostname, { allowHostNameMatch: true }); + return match?.identityAgent; } diff --git a/src/main/core/ssh/utils.ts b/src/main/core/ssh/utils.ts index e7ebd9770..f19f07c39 100644 --- a/src/main/core/ssh/utils.ts +++ b/src/main/core/ssh/utils.ts @@ -1,18 +1,9 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; -import { parseSshConfigFile } from '@main/core/ssh/sshConfigParser'; +import { resolveSshConfigHost } from '@main/core/ssh/sshConfigParser'; export async function resolveIdentityAgent(hostname: string): Promise { - try { - const hosts = await parseSshConfigFile(); - const match = hosts.find( - (h) => - h.host.toLowerCase() === hostname.toLowerCase() || - h.hostname?.toLowerCase() === hostname.toLowerCase() - ); - return match?.identityAgent; - } catch { - return undefined; - } + const match = await resolveSshConfigHost(hostname, { allowHostNameMatch: true }); + return match?.identityAgent; } export async function resolveRemoteHome(ctx: IExecutionContext): Promise { diff --git a/src/shared/ssh.ts b/src/shared/ssh.ts index 67eeaee4d..962a0b64d 100644 --- a/src/shared/ssh.ts +++ b/src/shared/ssh.ts @@ -123,4 +123,5 @@ export interface SshConfigHost { port?: number; identityFile?: string; identityAgent?: string; + proxyJump?: string; }