Skip to content
107 changes: 107 additions & 0 deletions src/main/core/ssh/build-connect-config.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
36 changes: 28 additions & 8 deletions src/main/core/ssh/build-connect-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,41 @@ 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`.
*/
export async function buildConnectConfigFromRow(
row: SshConnectionRow
): Promise<ConnectConfig | undefined> {
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<ConnectConfig> = {};

switch (row.authType) {
case 'password': {
const password = await sshCredentialService.getPassword(row.id);
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': {
Expand All @@ -41,23 +51,33 @@ 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(
`SSH agent socket not found for connection '${row.name}'. ` +
'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;
}
149 changes: 149 additions & 0 deletions src/main/core/ssh/controller.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<(...args: unknown[]) => 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,
});
});
});
Loading