Skip to content

Commit 610da08

Browse files
author
Girish Konda
committed
agentHost: honor SSH ProxyJump when connecting
1 parent 36053db commit 610da08

6 files changed

Lines changed: 342 additions & 3 deletions

File tree

‎src/vs/platform/agentHost/common/sshConfigParsing.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,13 @@ export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
7878
: strictHostKeyCheckingValue === 'false'
7979
? 'no'
8080
: strictHostKeyCheckingValue;
81+
const proxyJump = map.get('proxyjump');
8182

8283
return {
8384
hostname: map.get('hostname') ?? '',
8485
user: map.get('user') || undefined,
8586
port: parseInt(map.get('port') ?? '22', 10),
87+
proxyJump: proxyJump?.toLowerCase() === 'none' ? undefined : proxyJump,
8688
identityFile: identityFiles,
8789
identityAgent: map.get('identityagent') || undefined,
8890
forwardAgent: map.get('forwardagent') === 'yes',

‎src/vs/platform/agentHost/common/sshRemoteAgentHost.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface ISSHAgentHostConfig {
5757
readonly name: string;
5858
/** SSH config host alias (e.g. "robfast2") for reconnection on restart. */
5959
readonly sshConfigHost?: string;
60+
readonly proxyJump?: string;
6061
/** Dev override: custom command to start the remote agent host instead of the default CLI. */
6162
readonly remoteAgentHostCommand?: string;
6263
/** When true, enables OpenSSH agent forwarding (auth-agent@openssh.com) for this connection. Requires {@link authMethod} to be Agent. */
@@ -273,6 +274,7 @@ export interface ISSHResolvedConfig {
273274
readonly hostname: string;
274275
readonly user: string | undefined;
275276
readonly port: number;
277+
readonly proxyJump?: string;
276278
readonly identityFile: string[];
277279
readonly identityAgent: string | undefined;
278280
readonly forwardAgent: boolean;

‎src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts‎

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';
88
import { promises as fsp } from 'fs';
99
import * as os from 'os';
1010
import * as cp from 'child_process';
11+
import { Duplex } from 'stream';
1112
import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';
1213
import { Emitter, Event } from '../../../base/common/event.js';
1314
import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js';
@@ -73,6 +74,7 @@ import {
7374
import { ensureRemoteAgentHostCliInstalled, type IRemoteAgentHostCliInstallResult } from './remoteAgentHostCliInstaller.js';
7475
import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
7576
import { removeAnsiEscapeCodes } from '../../../base/common/strings.js';
77+
import { killTree } from '../../../base/node/processes.js';
7678

7779
/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */
7880
interface SSHChannel extends NodeJS.ReadWriteStream {
@@ -103,6 +105,11 @@ interface SSHClient {
103105
end(): void;
104106
}
105107

108+
interface ISSHProxyTransport {
109+
readonly socket: Duplex;
110+
dispose(): void;
111+
}
112+
106113
const LOG_PREFIX = '[SSHRemoteAgentHost]';
107114

108115
/**
@@ -1197,6 +1204,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
11971204
authMethod: SSHAuthMethod.Agent,
11981205
privateKeyPath,
11991206
identityAgent: resolved.identityAgent,
1207+
proxyJump: resolved.proxyJump,
12001208
name,
12011209
sshConfigHost,
12021210
remoteAgentHostCommand,
@@ -1342,6 +1350,134 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
13421350
return parseSSHGOutput(stdout);
13431351
}
13441352

1353+
private _parseProxyJump(proxyJump: string): { destination: string; port?: number } {
1354+
if (proxyJump.includes(',')) {
1355+
throw new Error(localize('ssh.proxyJumpChainUnsupported', "SSH ProxyJump chains are not supported."));
1356+
}
1357+
1358+
const atIndex = proxyJump.lastIndexOf('@');
1359+
const user = atIndex === -1 ? undefined : proxyJump.substring(0, atIndex);
1360+
const hostAndPort = proxyJump.substring(atIndex + 1);
1361+
let host: string;
1362+
let portText: string | undefined;
1363+
if (hostAndPort.startsWith('[')) {
1364+
const bracketIndex = hostAndPort.indexOf(']');
1365+
host = hostAndPort.substring(0, bracketIndex + 1);
1366+
const suffix = hostAndPort.substring(bracketIndex + 1);
1367+
if (bracketIndex <= 1 ||
1368+
hostAndPort.indexOf('[', 1) !== -1 ||
1369+
hostAndPort.indexOf(']', bracketIndex + 1) !== -1 ||
1370+
(suffix && !suffix.startsWith(':'))) {
1371+
throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration is invalid."));
1372+
}
1373+
portText = suffix ? suffix.substring(1) : undefined;
1374+
} else {
1375+
if (hostAndPort.includes('[') || hostAndPort.includes(']')) {
1376+
throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration is invalid."));
1377+
}
1378+
const colonIndex = hostAndPort.lastIndexOf(':');
1379+
if (colonIndex !== -1 &&
1380+
hostAndPort.indexOf(':') !== colonIndex) {
1381+
throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration is invalid."));
1382+
}
1383+
host = colonIndex === -1 ? hostAndPort : hostAndPort.substring(0, colonIndex);
1384+
portText = colonIndex === -1 ? undefined : hostAndPort.substring(colonIndex + 1);
1385+
}
1386+
const invalidPort = portText !== undefined &&
1387+
(!portText || [...portText].some(character => character < '0' || character > '9'));
1388+
const port = invalidPort ? undefined : portText === undefined ? undefined : Number(portText);
1389+
if (!host ||
1390+
user === '' ||
1391+
invalidPort ||
1392+
(port !== undefined && (port < 1 || port > 65535))) {
1393+
throw new Error(localize('ssh.invalidProxyJump', "The SSH ProxyJump configuration is invalid."));
1394+
}
1395+
return {
1396+
destination: `${user ? `${user}@` : ''}${host}`,
1397+
port,
1398+
};
1399+
}
1400+
1401+
protected _spawnProxyProcess(command: string, args: readonly string[]): cp.ChildProcessWithoutNullStreams {
1402+
return cp.spawn(command, args, {
1403+
stdio: ['pipe', 'pipe', 'pipe'],
1404+
windowsHide: true,
1405+
});
1406+
}
1407+
1408+
protected _killProxyProcess(pid: number): Promise<void> {
1409+
return killTree(pid, true);
1410+
}
1411+
1412+
protected async _createProxyTransport(config: ISSHAgentHostConfig): Promise<ISSHProxyTransport | undefined> {
1413+
if (!config.sshConfigHost ||
1414+
!config.proxyJump) {
1415+
return undefined;
1416+
}
1417+
1418+
const jump = this._parseProxyJump(config.proxyJump);
1419+
const targetHost = config.host.includes(':') ? `[${config.host}]` : config.host;
1420+
const args = ['-o', 'BatchMode=yes'];
1421+
if (jump.port !== undefined) {
1422+
args.push('-p', String(jump.port));
1423+
}
1424+
args.push('-W', `${targetHost}:${config.port ?? 22}`, '--', jump.destination);
1425+
const child = this._spawnProxyProcess('ssh', args);
1426+
await new Promise<void>((resolve, reject) => {
1427+
const onError = (error: Error) => {
1428+
child.removeListener('spawn', onSpawn);
1429+
child.stdin.destroy();
1430+
child.stdout.destroy();
1431+
child.stderr.destroy();
1432+
reject(error);
1433+
};
1434+
const onSpawn = () => {
1435+
child.removeListener('error', onError);
1436+
resolve();
1437+
};
1438+
child.once('error', onError);
1439+
child.once('spawn', onSpawn);
1440+
});
1441+
child.stderr.resume();
1442+
const socket = Duplex.from({ readable: child.stdout, writable: child.stdin });
1443+
let disposed = false;
1444+
const dispose = () => {
1445+
if (disposed) {
1446+
return;
1447+
}
1448+
disposed = true;
1449+
socket.destroy();
1450+
child.stdin.destroy();
1451+
child.stdout.destroy();
1452+
child.stderr.destroy();
1453+
if (child.pid !== undefined &&
1454+
child.exitCode === null &&
1455+
child.signalCode === null) {
1456+
void this._killProxyProcess(child.pid).catch(() => {
1457+
if (child.exitCode === null &&
1458+
child.signalCode === null) {
1459+
child.kill();
1460+
}
1461+
});
1462+
}
1463+
};
1464+
child.once('error', error => socket.destroy(error));
1465+
child.once('exit', (code, signal) => {
1466+
if (!disposed &&
1467+
(code !== 0 || signal !== null)) {
1468+
socket.destroy(new Error(localize(
1469+
'ssh.proxyProcessExited',
1470+
"SSH proxy process exited before the connection closed (code {0}, signal {1}).",
1471+
code ?? 'none',
1472+
signal ?? 'none',
1473+
)));
1474+
}
1475+
});
1476+
socket.on('error', () => { });
1477+
socket.once('close', dispose);
1478+
return { socket, dispose };
1479+
}
1480+
13451481
protected async _connectSSH(
13461482
config: ISSHAgentHostConfig,
13471483
connectionKey?: string,
@@ -1485,6 +1621,16 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
14851621
};
14861622

14871623
const client = await this._createSSHClient();
1624+
let proxyTransport: ISSHProxyTransport | undefined;
1625+
try {
1626+
proxyTransport = await this._createProxyTransport(config);
1627+
if (proxyTransport) {
1628+
connectConfig.sock = proxyTransport.socket;
1629+
}
1630+
} catch (error) {
1631+
client.end();
1632+
throw error;
1633+
}
14881634
return new Promise<SSHClient>((resolve, reject) => {
14891635
let settled = false;
14901636
let deadlineTimer: IHandshakeDeadlineHandle | undefined;
@@ -1526,6 +1672,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
15261672
clearDeadline();
15271673
cancelLiveKbiRequests();
15281674
cancelLiveHostKeyRequests();
1675+
proxyTransport?.dispose();
15291676
if (endClient) {
15301677
client.end();
15311678
}
@@ -1555,6 +1702,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
15551702
// connect promise would never settle and any outstanding host key
15561703
// prompt would be left on screen forever.
15571704
client.on('close', () => {
1705+
proxyTransport?.dispose();
15581706
rejectConnect(
15591707
hostKeyDenied
15601708
? new SSHHostKeyDeniedError(displayHost)
@@ -1573,7 +1721,11 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
15731721
});
15741722

15751723
armDeadline(HANDSHAKE_TIMEOUT_MS);
1576-
client.connect(connectConfig);
1724+
try {
1725+
client.connect(connectConfig);
1726+
} catch (error) {
1727+
rejectConnect(error instanceof Error ? error : new Error(String(error)), false);
1728+
}
15771729
});
15781730
}
15791731

‎src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,14 @@ suite('SSH Config Parsing', () => {
130130
'identityfile ~/.ssh/id_rsa',
131131
'identityfile ~/.ssh/id_ed25519',
132132
'forwardagent no',
133+
'proxyjump jumpserver',
133134
].join('\n');
134135

135136
assert.deepStrictEqual(parseSSHGOutput(output), {
136137
hostname: '10.0.0.1',
137138
user: 'admin',
138139
port: 22,
140+
proxyJump: 'jumpserver',
139141
identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'],
140142
identityAgent: undefined,
141143
forwardAgent: false,
@@ -145,6 +147,11 @@ suite('SSH Config Parsing', () => {
145147
});
146148
});
147149

150+
test('treats ProxyJump none as absent', () => {
151+
const result = parseSSHGOutput('proxyjump none');
152+
assert.strictEqual(result.proxyJump, undefined);
153+
});
154+
148155
test('parses forwardagent yes', () => {
149156
const output = [
150157
'hostname example.com',
@@ -226,6 +233,7 @@ suite('SSH Config Parsing', () => {
226233
hostname: '',
227234
user: undefined,
228235
port: 22,
236+
proxyJump: undefined,
229237
identityFile: [],
230238
identityAgent: undefined,
231239
forwardAgent: false,

0 commit comments

Comments
 (0)