@@ -8,6 +8,7 @@ import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2';
88import { promises as fsp } from 'fs' ;
99import * as os from 'os' ;
1010import * as cp from 'child_process' ;
11+ import { Duplex } from 'stream' ;
1112import { dirname , join , isAbsolute , basename } from '../../../base/common/path.js' ;
1213import { Emitter , Event } from '../../../base/common/event.js' ;
1314import { Disposable , DisposableMap , toDisposable } from '../../../base/common/lifecycle.js' ;
@@ -73,6 +74,7 @@ import {
7374import { ensureRemoteAgentHostCliInstalled , type IRemoteAgentHostCliInstallResult } from './remoteAgentHostCliInstaller.js' ;
7475import { parseSSHConfigHostEntries , parseSSHGOutput , stripSSHComment } from '../common/sshConfigParsing.js' ;
7576import { 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). */
7880interface 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+
106113const 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
0 commit comments