Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type WebSocket from 'ws';
7
import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';
8
import { promises as fsp } from 'fs';
9
import * as os from 'os';
10
import * as cp from 'child_process';
11
import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';
12
import { Emitter, Event } from '../../../base/common/event.js';
13
import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js';
14
import { URI } from '../../../base/common/uri.js';
15
import { localize } from '../../../nls.js';
16
import { ILogService } from '../../log/common/log.js';
17
import { IProductService } from '../../product/common/productService.js';
18
import {
19
ISSHRemoteAgentHostMainService,
20
SSHAuthMethod,
21
type ISSHAgentHostConfig,
22
type ISSHAgentHostConfigSanitized,
23
type ISSHConnectProgress,
24
type ISSHConnectResult,
25
type ISSHRelayMessage,
26
type ISSHResolvedConfig,
27
} from '../common/sshRemoteAgentHost.js';
28
import {
29
buildCLIDownloadUrl,
30
cleanupRemoteAgentHost,
31
findRunningAgentHost,
32
getRemoteCLIBin,
33
getRemoteCLIDir,
34
redactToken,
35
resolveRemotePlatform,
36
shellEscape,
37
writeAgentHostState,
38
} from './sshRemoteAgentHostHelpers.js';
39
import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
40
41
/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */
42
interface SSHChannel extends NodeJS.ReadWriteStream {
43
on(event: 'data', listener: (data: Buffer) => void): this;
44
on(event: 'close', listener: (code: number) => void): this;
45
on(event: 'error', listener: (err: Error) => void): this;
46
on(event: string, listener: (...args: unknown[]) => void): this;
47
stderr: { on(event: 'data', listener: (data: Buffer) => void): void };
48
close(): void;
49
}
50
51
/** Minimal subset of ssh2.Client used by this module. */
52
interface SSHClient {
53
on(event: 'ready', listener: () => void): SSHClient;
54
on(event: 'error', listener: (err: Error) => void): SSHClient;
55
on(event: 'close', listener: () => void): SSHClient;
56
removeListener(event: 'close', listener: () => void): SSHClient;
57
removeListener(event: 'error', listener: (err: Error) => void): SSHClient;
58
connect(config: ConnectConfig): void;
59
exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient;
60
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient;
61
end(): void;
62
}
63
64
const LOG_PREFIX = '[SSHRemoteAgentHost]';
65
66
/**
67
* One entry in the queue of authentication attempts handed to ssh2's
68
* `authHandler`. Each attempt corresponds to one of the auth method shapes
69
* documented at https://www.npmjs.com/package/ssh2#client-methods.
70
*
71
* `keyPath` is internal-only metadata for logging — it is stripped before the
72
* attempt is returned to ssh2.
73
*/
74
export type SSHAuthAttempt =
75
| { readonly type: 'publickey'; readonly username: string; readonly key: Buffer; readonly keyPath: string }
76
| { readonly type: 'agent'; readonly username: string; readonly agent: string }
77
| { readonly type: 'password'; readonly username: string; readonly password: string };
78
79
function describeAuthAttempt(attempt: SSHAuthAttempt): string {
80
switch (attempt.type) {
81
case 'publickey': return `publickey ${attempt.keyPath}`;
82
case 'agent': return 'agent';
83
case 'password': return 'password';
84
}
85
}
86
87
/**
88
* Build an ssh2 `authHandler` callback that walks the given attempts in order,
89
* filtering by the server-advertised `methodsLeft` when ssh2 provides one.
90
* Returns `false` when the queue is exhausted, which causes ssh2 to surface
91
* an authentication failure to the caller.
92
*/
93
export function makeAuthHandler(
94
attempts: readonly SSHAuthAttempt[],
95
logService: ILogService,
96
): (methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void {
97
let index = 0;
98
return (methodsLeft, _partialSuccess, callback) => {
99
while (index < attempts.length) {
100
const attempt = attempts[index++];
101
// `agent` is a publickey-flavored method at the SSH protocol level —
102
// servers advertise `publickey`, not `agent`, in `methodsLeft`.
103
const protocolMethod: AuthenticationType = attempt.type === 'agent' ? 'publickey' : attempt.type;
104
if (methodsLeft && !methodsLeft.includes(protocolMethod)) {
105
logService.info(`${LOG_PREFIX} Skipping ${describeAuthAttempt(attempt)} — server only allows ${methodsLeft.join(', ')}`);
106
continue;
107
}
108
logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`);
109
// Strip our internal `keyPath` metadata before handing to ssh2.
110
if (attempt.type === 'publickey') {
111
const { keyPath: _kp, ...payload } = attempt;
112
callback(payload);
113
} else {
114
callback(attempt);
115
}
116
return;
117
}
118
logService.info(`${LOG_PREFIX} No more auth methods to try; giving up`);
119
callback(false);
120
};
121
}
122
123
function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {
124
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
125
client.exec(command, (err: Error | undefined, stream: SSHChannel) => {
126
if (err) {
127
reject(err);
128
return;
129
}
130
131
let stdout = '';
132
let stderr = '';
133
let settled = false;
134
135
const finish = (error: Error | undefined, code: number | undefined) => {
136
if (settled) {
137
return;
138
}
139
settled = true;
140
if (error) {
141
reject(error);
142
return;
143
}
144
if (code !== 0 && !opts?.ignoreExitCode) {
145
reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`));
146
} else {
147
resolve({ stdout, stderr, code: code ?? 0 });
148
}
149
};
150
151
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
152
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
153
stream.on('error', (streamErr: Error) => finish(streamErr, undefined));
154
stream.on('close', (code: number) => finish(undefined, code));
155
});
156
});
157
}
158
159
/** Create a bound exec function for the given SSH client. */
160
function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCode?: boolean }) => Promise<{ stdout: string; stderr: string; code: number }> {
161
return (command, opts) => sshExec(client, command, opts);
162
}
163
164
function startRemoteAgentHost(
165
client: SSHClient,
166
logService: ILogService,
167
quality: string,
168
commandOverride?: string,
169
): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
170
return new Promise((resolve, reject) => {
171
const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent host --port 0 --accept-server-license-terms`;
172
// Wrap in a login shell so the agent host process inherits the
173
// user's PATH and environment from ~/.bash_profile / ~/.bashrc
174
// (ssh2 exec runs a non-interactive non-login shell by default).
175
// Echo the PID so we can record it for process reuse detection.
176
const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${baseCmd}`)}`;
177
logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`);
178
179
client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => {
180
if (err) {
181
reject(err);
182
return;
183
}
184
185
let resolved = false;
186
let outputBuf = '';
187
let pid: number | undefined;
188
189
const timeout = setTimeout(() => {
190
if (!resolved) {
191
resolved = true;
192
reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`));
193
}
194
}, 60_000);
195
196
const checkForOutput = () => {
197
if (pid === undefined) {
198
const pidMatch = outputBuf.match(/VSCODE_PID=(\d+)/);
199
if (pidMatch) {
200
pid = parseInt(pidMatch[1], 10);
201
logService.info(`${LOG_PREFIX} Remote agent host PID: ${pid}`);
202
}
203
}
204
205
if (!resolved) {
206
const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/);
207
if (match) {
208
resolved = true;
209
clearTimeout(timeout);
210
const port = parseInt(match[1], 10);
211
const connectionToken = match[2] || undefined;
212
logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`);
213
resolve({ port, connectionToken, pid, stream });
214
}
215
}
216
};
217
218
stream.stderr.on('data', (data: Buffer) => {
219
const text = data.toString();
220
outputBuf += text;
221
logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);
222
checkForOutput();
223
});
224
225
stream.on('data', (data: Buffer) => {
226
const text = data.toString();
227
outputBuf += text;
228
logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);
229
checkForOutput();
230
});
231
232
stream.on('error', (streamErr: Error) => {
233
if (!resolved) {
234
resolved = true;
235
clearTimeout(timeout);
236
reject(streamErr);
237
}
238
});
239
240
stream.on('close', (code: number) => {
241
if (!resolved) {
242
resolved = true;
243
clearTimeout(timeout);
244
reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`));
245
}
246
});
247
});
248
});
249
}
250
251
/**
252
* Create a WebSocket connection to the remote agent host via an SSH forwarded channel.
253
* Uses the `ws` library to speak WebSocket over the SSH channel.
254
* Messages are relayed to the renderer via IPC events.
255
*/
256
function createWebSocketRelay(
257
nativeRequire: NodeJS.Require,
258
client: SSHClient,
259
dstHost: string,
260
dstPort: number,
261
connectionToken: string | undefined,
262
logService: ILogService,
263
onMessage: (data: string) => void,
264
onClose: () => void,
265
): Promise<{ send: (data: string) => void; close: () => void }> {
266
return new Promise((resolve, reject) => {
267
client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => {
268
if (err) {
269
reject(err);
270
return;
271
}
272
273
const WS = nativeRequire('ws') as typeof WebSocket;
274
let url = `ws://${dstHost}:${dstPort}`;
275
if (connectionToken) {
276
url += `?tkn=${encodeURIComponent(connectionToken)}`;
277
}
278
279
// The SSH channel is a duplex stream compatible with ws's createConnection,
280
// but our minimal SSHChannel interface doesn't carry the full Node Duplex shape.
281
const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] });
282
283
ws.on('open', () => {
284
logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`);
285
resolve({
286
send: (data: string) => {
287
if (ws.readyState === ws.OPEN) {
288
ws.send(data);
289
}
290
},
291
close: () => ws.close(),
292
});
293
});
294
295
ws.on('message', (data: WebSocket.RawData) => {
296
if (Array.isArray(data)) {
297
onMessage(Buffer.concat(data).toString());
298
} else if (data instanceof ArrayBuffer) {
299
onMessage(Buffer.from(new Uint8Array(data)).toString());
300
} else {
301
onMessage(data.toString());
302
}
303
});
304
305
ws.on('close', onClose);
306
307
ws.on('error', (wsErr: unknown) => {
308
logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
309
reject(wsErr);
310
});
311
});
312
});
313
}
314
315
function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized {
316
const { password: _p, privateKeyPath: _k, ...sanitized } = config;
317
return sanitized;
318
}
319
320
/**
321
* State for a single active SSH relay connection.
322
* Immutable and dispose-once — follows the same pattern as TunnelConnection.
323
* On reconnect, the old SSHConnection is disposed and a fresh one is created;
324
* the SSH client can be detached first so only the WebSocket relay is torn down.
325
*/
326
class SSHConnection extends Disposable {
327
private readonly _onDidClose = new Emitter<void>();
328
readonly onDidClose = this._onDidClose.event;
329
330
readonly config: ISSHAgentHostConfigSanitized;
331
private _closed = false;
332
private _sshClientDetached = false;
333
private readonly _sshCloseListener = () => { this.dispose(); };
334
private readonly _sshErrorListener = () => { this.dispose(); };
335
336
constructor(
337
fullConfig: ISSHAgentHostConfig,
338
readonly connectionId: string,
339
readonly address: string,
340
readonly name: string,
341
readonly connectionToken: string | undefined,
342
readonly remotePort: number,
343
readonly sshClient: SSHClient,
344
private readonly _relay: { send: (data: string) => void; close: () => void },
345
private readonly _remoteStream: SSHChannel | undefined,
346
) {
347
super();
348
349
this.config = sanitizeConfig(fullConfig);
350
351
// Register cleanup first so it fires _onDidClose *before* the Emitter is disposed.
352
this._register(toDisposable(() => {
353
if (this._closed) {
354
return;
355
}
356
this._closed = true;
357
this._relay.close();
358
if (!this._sshClientDetached) {
359
this._remoteStream?.close();
360
sshClient.end();
361
}
362
this._onDidClose.fire();
363
}));
364
365
this._register(this._onDidClose);
366
367
sshClient.on('close', this._sshCloseListener);
368
sshClient.on('error', this._sshErrorListener);
369
}
370
371
/**
372
* Detach the SSH client from this connection so that `dispose()`
373
* only closes the WebSocket relay without ending the SSH session.
374
* Also removes event listeners from the SSH client so the old
375
* connection object is not retained by the shared client.
376
*/
377
detachSshClient(): void {
378
this._sshClientDetached = true;
379
this.sshClient.removeListener('close', this._sshCloseListener);
380
this.sshClient.removeListener('error', this._sshErrorListener);
381
}
382
383
relaySend(data: string): void {
384
this._relay.send(data);
385
}
386
}
387
388
export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {
389
declare readonly _serviceBrand: undefined;
390
391
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
392
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
393
394
private readonly _onDidCloseConnection = this._register(new Emitter<string>());
395
readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;
396
397
private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());
398
readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;
399
400
private readonly _onDidRelayMessage = this._register(new Emitter<ISSHRelayMessage>());
401
readonly onDidRelayMessage: Event<ISSHRelayMessage> = this._onDidRelayMessage.event;
402
403
private readonly _onDidRelayClose = this._register(new Emitter<string>());
404
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
405
406
private readonly _connections = this._register(new DisposableMap<string, SSHConnection>());
407
408
private _nativeRequire: NodeJS.Require | undefined;
409
410
constructor(
411
@ILogService private readonly _logService: ILogService,
412
@IProductService private readonly _productService: IProductService,
413
) {
414
super();
415
}
416
417
/**
418
* Lazily load a `require` function for native modules (`ssh2`, `ws`).
419
* Uses a dynamic `import('node:module')` so the module is only resolved
420
* when actually needed at runtime — not at file-load time. This matters
421
* because tests override the methods that call this and never trigger
422
* the import, avoiding issues with Electron's ESM loader which cannot
423
* resolve `node:` specifiers.
424
*/
425
private async _getNativeRequire(): Promise<NodeJS.Require> {
426
if (!this._nativeRequire) {
427
const nodeModule = await import('node:module');
428
this._nativeRequire = nodeModule.createRequire(import.meta.url);
429
}
430
return this._nativeRequire;
431
}
432
433
async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise<ISSHConnectResult> {
434
const connectionKey = config.sshConfigHost
435
? `ssh:${config.sshConfigHost}`
436
: `${config.username}@${config.host}:${config.port ?? 22}`;
437
438
const existing = this._connections.get(connectionKey);
439
if (existing) {
440
if (replaceRelay) {
441
// Tear down the old relay and create a fresh one, following
442
// the same dispose-and-recreate pattern as TunnelAgentHostMainService.
443
// The SSH client is detached so only the WebSocket relay is closed.
444
this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`);
445
const { sshClient, remotePort, connectionToken } = existing;
446
447
// Remove from map and detach SSH client before disposing so
448
// the old relay's close handler (conn?.dispose()) is a no-op.
449
this._connections.deleteAndLeak(connectionKey);
450
existing.detachSshClient();
451
existing.dispose();
452
453
// Create fresh relay and connection. If relay creation fails,
454
// clean up the detached SSH client so it doesn't leak.
455
const connectionId = connectionKey;
456
try {
457
let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
458
const relay = await this._createWebSocketRelay(
459
sshClient, '127.0.0.1', remotePort, connectionToken,
460
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
461
() => { conn?.dispose(); },
462
);
463
464
conn = new SSHConnection(
465
config, connectionId, connectionKey, config.name,
466
connectionToken, remotePort, sshClient, relay, undefined,
467
);
468
469
Event.once(conn.onDidClose)(() => {
470
if (this._connections.get(connectionKey) === conn) {
471
this._connections.deleteAndDispose(connectionKey);
472
this._onDidRelayClose.fire(connectionId);
473
this._onDidCloseConnection.fire(connectionId);
474
this._onDidChangeConnections.fire();
475
}
476
});
477
478
this._connections.set(connectionKey, conn);
479
480
return {
481
connectionId: conn.connectionId,
482
address: conn.address,
483
name: conn.name,
484
connectionToken: conn.connectionToken,
485
config: conn.config,
486
sshConfigHost: config.sshConfigHost,
487
};
488
} catch (err) {
489
sshClient.end();
490
this._onDidRelayClose.fire(connectionId);
491
this._onDidCloseConnection.fire(connectionId);
492
this._onDidChangeConnections.fire();
493
throw err;
494
}
495
}
496
497
return {
498
connectionId: existing.connectionId,
499
address: existing.address,
500
name: existing.name,
501
connectionToken: existing.connectionToken,
502
config: existing.config,
503
sshConfigHost: config.sshConfigHost,
504
};
505
}
506
507
this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`);
508
let sshClient: SSHClient | undefined;
509
510
try {
511
const reportProgress = (message: string) => {
512
this._onDidReportConnectProgress.fire({ connectionKey, message });
513
};
514
515
// 1. Establish SSH connection
516
reportProgress(localize('sshProgressConnecting', "Establishing SSH connection..."));
517
sshClient = await this._connectSSH(config);
518
519
if (config.remoteAgentHostCommand) {
520
// Dev override: skip platform detection and CLI install,
521
// use the provided command directly.
522
this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`);
523
} else {
524
// 2. Detect remote platform
525
const { stdout: unameS } = await sshExec(sshClient, 'uname -s');
526
const { stdout: unameM } = await sshExec(sshClient, 'uname -m');
527
const platform = resolveRemotePlatform(unameS, unameM);
528
if (!platform) {
529
throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`);
530
}
531
this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`);
532
533
// 3. Install CLI if needed
534
reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));
535
await this._ensureCLIInstalled(sshClient, platform, reportProgress);
536
}
537
538
// 4. Check for an already-running agent host on the remote.
539
// This prevents accumulating orphaned processes when the SSH
540
// connection drops and we reconnect.
541
let remotePort: number | undefined;
542
let connectionToken: string | undefined;
543
let agentStream: SSHChannel | undefined;
544
545
reportProgress(localize('sshProgressCheckingAgent', "Checking for existing agent host..."));
546
const exec = bindSshExec(sshClient);
547
const existingAH = await findRunningAgentHost(exec, this._logService, this._quality);
548
if (existingAH) {
549
remotePort = existingAH.port;
550
connectionToken = existingAH.connectionToken;
551
}
552
553
if (remotePort === undefined) {
554
// 5. Start agent-host and capture port/token
555
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
556
const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand);
557
remotePort = result.port;
558
connectionToken = result.connectionToken;
559
agentStream = result.stream;
560
561
// Record state for future reuse
562
await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken);
563
}
564
565
// 6. Connect to remote agent host via WebSocket relay (no local TCP port)
566
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
567
const connectionId = connectionKey;
568
let conn: SSHConnection | undefined; // eslint-disable-line prefer-const
569
let relay: { send: (data: string) => void; close: () => void };
570
try {
571
relay = await this._createWebSocketRelay(
572
sshClient, '127.0.0.1', remotePort, connectionToken,
573
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
574
() => { conn?.dispose(); },
575
);
576
} catch (relayErr) {
577
if (!existingAH) {
578
throw relayErr;
579
}
580
// The reused agent host is not connectable — kill it and start fresh
581
const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr);
582
this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on port ${remotePort}: ${relayErrorMessage}. Starting fresh`);
583
await cleanupRemoteAgentHost(exec, this._logService, this._quality);
584
585
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
586
const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand);
587
remotePort = result.port;
588
connectionToken = result.connectionToken;
589
agentStream = result.stream;
590
await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken);
591
592
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
593
relay = await this._createWebSocketRelay(
594
sshClient, '127.0.0.1', remotePort, connectionToken,
595
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
596
() => { conn?.dispose(); },
597
);
598
}
599
600
// 7. Create connection object
601
const address = connectionKey;
602
conn = new SSHConnection(
603
config,
604
connectionId,
605
address,
606
config.name,
607
connectionToken,
608
remotePort,
609
sshClient,
610
relay,
611
agentStream,
612
);
613
614
Event.once(conn.onDidClose)(() => {
615
if (this._connections.get(connectionKey) === conn) {
616
this._connections.deleteAndDispose(connectionKey);
617
this._onDidRelayClose.fire(connectionId);
618
this._onDidCloseConnection.fire(connectionId);
619
this._onDidChangeConnections.fire();
620
}
621
});
622
623
this._connections.set(connectionKey, conn);
624
sshClient = undefined; // ownership transferred to SSHConnection
625
626
this._onDidChangeConnections.fire();
627
628
return {
629
connectionId,
630
address,
631
name: config.name,
632
connectionToken,
633
config: conn.config,
634
sshConfigHost: config.sshConfigHost,
635
};
636
637
} catch (err) {
638
sshClient?.end();
639
throw err;
640
}
641
}
642
643
async disconnect(host: string): Promise<void> {
644
for (const [key, conn] of this._connections) {
645
if (key === host || conn.connectionId === host) {
646
conn.dispose();
647
return;
648
}
649
}
650
}
651
652
async relaySend(connectionId: string, message: string): Promise<void> {
653
for (const conn of this._connections.values()) {
654
if (conn.connectionId === connectionId) {
655
conn.relaySend(message);
656
return;
657
}
658
}
659
}
660
661
async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise<ISSHConnectResult> {
662
this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);
663
const resolved = await this.resolveSSHConfig(sshConfigHost);
664
665
// Always use Agent auth — the auth handler will walk through the SSH
666
// agent and any default identities. If the user pinned a non-default
667
// `IdentityFile` in their ssh config, surface it as the explicit key
668
// so it gets tried first.
669
let privateKeyPath: string | undefined;
670
if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._isDefaultKeyPath(resolved.identityFile[0])) {
671
privateKeyPath = resolved.identityFile[0];
672
}
673
this._logService.info(`${LOG_PREFIX} reconnect: identityFiles=${JSON.stringify(resolved.identityFile)}, explicit key=${privateKeyPath ?? '(none)'}`);
674
675
return this.connect({
676
host: resolved.hostname,
677
port: resolved.port !== 22 ? resolved.port : undefined,
678
username: resolved.user ?? sshConfigHost,
679
authMethod: SSHAuthMethod.Agent,
680
privateKeyPath,
681
name,
682
sshConfigHost,
683
remoteAgentHostCommand,
684
agentForward: agentForward && resolved.forwardAgent ? true : undefined,
685
}, /* replaceRelay */ true);
686
}
687
688
async listSSHConfigHosts(): Promise<string[]> {
689
const configPath = join(os.homedir(), '.ssh', 'config');
690
try {
691
const content = await fsp.readFile(configPath, 'utf-8');
692
return this._parseSSHConfigHosts(content, dirname(configPath));
693
} catch {
694
this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);
695
return [];
696
}
697
}
698
699
async ensureUserSSHConfig(): Promise<URI> {
700
const sshDir = join(os.homedir(), '.ssh');
701
const configPath = join(sshDir, 'config');
702
const isPosix = process.platform !== 'win32';
703
try {
704
await fsp.mkdir(sshDir, { recursive: true, mode: isPosix ? 0o700 : undefined });
705
} catch (err) {
706
this._logService.warn(`${LOG_PREFIX} Failed to ensure ~/.ssh directory: ${err}`);
707
throw err;
708
}
709
try {
710
await fsp.access(configPath);
711
} catch {
712
try {
713
const handle = await fsp.open(configPath, 'a', isPosix ? 0o600 : undefined);
714
await handle.close();
715
} catch (err) {
716
this._logService.warn(`${LOG_PREFIX} Failed to create ${configPath}: ${err}`);
717
throw err;
718
}
719
}
720
return URI.file(configPath);
721
}
722
723
async listSSHConfigFiles(): Promise<URI[]> {
724
const isWindows = process.platform === 'win32';
725
const userConfigPath = join(os.homedir(), '.ssh', 'config');
726
const systemConfigPath = isWindows
727
? join(process.env['ProgramData'] ?? 'C:\\ProgramData', 'ssh', 'ssh_config')
728
: '/etc/ssh/ssh_config';
729
730
const result: URI[] = [URI.file(userConfigPath)];
731
try {
732
await fsp.access(systemConfigPath);
733
result.push(URI.file(systemConfigPath));
734
} catch {
735
// system config file does not exist — skip
736
}
737
return result;
738
}
739
740
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
741
return new Promise<ISSHResolvedConfig>((resolve, reject) => {
742
cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {
743
if (err) {
744
reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));
745
return;
746
}
747
const config = this._parseSSHGOutput(stdout);
748
resolve(config);
749
});
750
});
751
}
752
753
private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {
754
const seen = visited ?? new Set<string>();
755
const hosts: string[] = [];
756
757
// Extract hosts from this file directly
758
hosts.push(...parseSSHConfigHostEntries(content));
759
760
// Follow Include directives
761
for (const line of content.split('\n')) {
762
const trimmed = line.trim();
763
if (!trimmed || trimmed.startsWith('#')) {
764
continue;
765
}
766
const includeMatch = trimmed.match(/^Include\s+(.+)$/i);
767
if (!includeMatch) {
768
continue;
769
}
770
771
const rawValue = stripSSHComment(includeMatch[1]);
772
const patterns = rawValue.split(/\s+/).filter(Boolean);
773
774
for (const rawPattern of patterns) {
775
const pattern = rawPattern.replace(/^~/, os.homedir());
776
const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);
777
778
if (seen.has(resolvedPattern)) {
779
continue;
780
}
781
seen.add(resolvedPattern);
782
783
try {
784
const stat = await fsp.stat(resolvedPattern);
785
if (stat.isDirectory()) {
786
const files = await fsp.readdir(resolvedPattern);
787
for (const file of files) {
788
try {
789
const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');
790
hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));
791
} catch { /* skip unreadable files */ }
792
}
793
} else {
794
const sub = await fsp.readFile(resolvedPattern, 'utf-8');
795
hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));
796
}
797
} catch {
798
const dir = dirname(resolvedPattern);
799
const base = basename(resolvedPattern);
800
if (base.includes('*')) {
801
try {
802
const files = await fsp.readdir(dir);
803
for (const file of files) {
804
const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
805
if (regex.test(file)) {
806
try {
807
const sub = await fsp.readFile(join(dir, file), 'utf-8');
808
hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));
809
} catch { /* skip */ }
810
}
811
}
812
} catch { /* skip unreadable dirs */ }
813
}
814
}
815
}
816
}
817
return hosts;
818
}
819
820
private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
821
return parseSSHGOutput(stdout);
822
}
823
824
protected async _connectSSH(
825
config: ISSHAgentHostConfig,
826
): Promise<SSHClient> {
827
const nativeRequire = await this._getNativeRequire();
828
const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown };
829
const SSHClientCtor = ssh2Module.Client;
830
831
const connectConfig: ConnectConfig = {
832
host: config.host,
833
port: config.port ?? 22,
834
username: config.username,
835
readyTimeout: 30_000,
836
keepaliveInterval: 15_000,
837
};
838
839
const attempts = await this._buildAuthAttempts(config);
840
this._logService.info(`${LOG_PREFIX} Built ${attempts.length} auth attempt(s): ${attempts.map(a => describeAuthAttempt(a)).join(', ')}`);
841
// Cast: the ssh2 @types don't model `false` (give-up) for the
842
// callback nor `null` for the first invocation's `methodsLeft`,
843
// even though the runtime supports both per the ssh2 docs.
844
connectConfig.authHandler = makeAuthHandler(attempts, this._logService) as unknown as ConnectConfig['authHandler'];
845
846
if (config.agentForward) {
847
const agentSock = this._isAgentAvailable();
848
if (agentSock) {
849
// ssh2 needs `connectConfig.agent` set so it knows which local
850
// agent socket to forward to. Without it, agent forwarding is a
851
// no-op even if `agentForward: true` is set.
852
connectConfig.agent = agentSock;
853
connectConfig.agentForward = true;
854
this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`);
855
} else {
856
this._logService.warn(`${LOG_PREFIX} SSH agent forwarding requested, but SSH_AUTH_SOCK is not set; agent forwarding disabled`);
857
}
858
}
859
860
return new Promise<SSHClient>((resolve, reject) => {
861
const client = new SSHClientCtor() as SSHClient;
862
863
client.on('ready', () => {
864
this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
865
resolve(client);
866
});
867
868
client.on('error', (err: Error) => {
869
this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
870
reject(err);
871
});
872
873
client.connect(connectConfig);
874
});
875
}
876
877
/**
878
* Build the ordered list of authentication attempts to feed to ssh2's
879
* `authHandler`. Mirrors OpenSSH's behavior: try the explicitly configured
880
* key first (if any), then the SSH agent (if `SSH_AUTH_SOCK` is set), then
881
* each readable default identity file in turn. This means a host that
882
* accepts `~/.ssh/id_rsa` still works even if the agent doesn't have it
883
* loaded — without needing an explicit `IdentityFile` entry in `~/.ssh/config`.
884
*/
885
protected async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise<SSHAuthAttempt[]> {
886
const attempts: SSHAuthAttempt[] = [];
887
const username = config.username;
888
889
switch (config.authMethod) {
890
case SSHAuthMethod.Agent: {
891
if (config.privateKeyPath) {
892
const explicit = await this._readKeyFileIfExists(config.privateKeyPath);
893
if (explicit) {
894
attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath });
895
}
896
}
897
const agentSock = this._isAgentAvailable();
898
if (agentSock) {
899
attempts.push({ type: 'agent', username, agent: agentSock });
900
}
901
for (const keyPath of SSHRemoteAgentHostMainService._defaultKeyPaths) {
902
if (config.privateKeyPath === keyPath) {
903
continue; // Already added as the explicit attempt above
904
}
905
const contents = await this._readKeyFileIfExists(keyPath);
906
if (contents) {
907
attempts.push({ type: 'publickey', username, key: contents, keyPath });
908
}
909
}
910
break;
911
}
912
case SSHAuthMethod.KeyFile: {
913
// KeyFile mode has no fallbacks — fail fast with a clear error if
914
// the key is missing or unreadable, rather than letting it surface
915
// downstream as a generic auth failure.
916
if (!config.privateKeyPath) {
917
throw new Error(localize('ssh.keyFileAuthRequiresPath', "Key file authentication requires a private key path."));
918
}
919
const explicit = await this._readKeyFileIfExists(config.privateKeyPath);
920
if (!explicit) {
921
throw new Error(localize('ssh.failedToReadPrivateKey', "Failed to read private key file: {0}", config.privateKeyPath));
922
}
923
attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath });
924
break;
925
}
926
case SSHAuthMethod.Password: {
927
if (config.password !== undefined) {
928
attempts.push({ type: 'password', username, password: config.password });
929
}
930
break;
931
}
932
}
933
934
return attempts;
935
}
936
937
private static readonly _defaultKeyPaths = [
938
'~/.ssh/id_ed25519',
939
'~/.ssh/id_rsa',
940
'~/.ssh/id_ecdsa',
941
'~/.ssh/id_dsa',
942
'~/.ssh/id_xmss',
943
];
944
945
private static _isDefaultKeyPath(keyPath: string): boolean {
946
return SSHRemoteAgentHostMainService._defaultKeyPaths.includes(keyPath);
947
}
948
949
/** Test seam: returns the SSH agent socket path, or undefined when no agent is available. */
950
protected _isAgentAvailable(): string | undefined {
951
return process.env['SSH_AUTH_SOCK'];
952
}
953
954
/**
955
* Test seam: read a private key file from disk. Returns `undefined` if the
956
* file doesn't exist; logs and returns `undefined` for any other read error
957
* so a single broken key doesn't abort the whole auth flow.
958
*/
959
protected async _readKeyFileIfExists(keyPath: string): Promise<Buffer | undefined> {
960
const resolved = keyPath.replace(/^~/, os.homedir());
961
try {
962
return await fsp.readFile(resolved);
963
} catch (error) {
964
const errorCode = (error as NodeJS.ErrnoException).code;
965
if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') {
966
return undefined;
967
}
968
this._logService.warn(`${LOG_PREFIX} Failed to read SSH key file ${resolved}`, error);
969
return undefined;
970
}
971
}
972
973
private get _quality(): string {
974
return this._productService.quality || 'insider';
975
}
976
977
protected _startRemoteAgentHost(
978
client: SSHClient, quality: string, commandOverride?: string,
979
): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> {
980
return startRemoteAgentHost(client, this._logService, quality, commandOverride);
981
}
982
983
protected async _createWebSocketRelay(
984
client: SSHClient, dstHost: string, dstPort: number, connectionToken: string | undefined,
985
onMessage: (data: string) => void, onClose: () => void,
986
): Promise<{ send: (data: string) => void; close: () => void }> {
987
const nativeRequire = await this._getNativeRequire();
988
return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose);
989
}
990
991
private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<void> {
992
const cliDir = getRemoteCLIDir(this._quality);
993
const cliBin = getRemoteCLIBin(this._quality);
994
const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
995
if (code === 0) {
996
this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`);
997
return;
998
}
999
1000
reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
1001
const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality);
1002
1003
const installCmd = [
1004
`mkdir -p ${cliDir}`,
1005
`curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`,
1006
`chmod +x ${cliBin}`,
1007
].join(' && ');
1008
1009
await sshExec(client, installCmd);
1010
this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`);
1011
}
1012
}
1013
1014