@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
import * as vscode from 'vscode' ;
13
13
import Log from './common/logger' ;
14
14
import { Disposable } from './common/dispose' ;
15
+ import { withServerApi } from './internalApi' ;
15
16
16
17
interface SSHConnectionParams {
17
18
workspaceId : string ;
@@ -63,10 +64,18 @@ function checkRunning(pid: number): true | Error {
63
64
}
64
65
}
65
66
66
- export default class LocalApp extends Disposable {
67
+ class LocalAppError extends Error {
68
+ override name = 'LocalAppError' ;
69
+
70
+ constructor ( message ?: string , readonly logPath ?: string ) {
71
+ super ( message ) ;
72
+ }
73
+ }
74
+
75
+ export default class RemoteConnector extends Disposable {
67
76
68
77
public static AUTH_COMPLETE_PATH = '/auth-complete' ;
69
- private static lockCount = 0 ;
78
+ private static LOCK_COUNT = 0 ;
70
79
71
80
constructor ( private readonly context : vscode . ExtensionContext , private readonly logger : Log ) {
72
81
super ( ) ;
@@ -100,7 +109,7 @@ export default class LocalApp extends Disposable {
100
109
private async withLock < T > ( lockName : string , op : ( token : vscode . CancellationToken ) => Promise < T > , timeout : number , token ?: vscode . CancellationToken ) : Promise < T > {
101
110
this . logger . info ( `acquiring lock: ${ lockName } ` ) ;
102
111
const lockKey = lockPrefix + lockName ;
103
- const value = vscode . env . sessionId + '/' + LocalApp . lockCount ++ ;
112
+ const value = vscode . env . sessionId + '/' + RemoteConnector . LOCK_COUNT ++ ;
104
113
let currentLock : Lock | undefined ;
105
114
let deadline : number | undefined ;
106
115
const updateTimeout = 150 ;
@@ -237,7 +246,7 @@ export default class LocalApp extends Disposable {
237
246
GITPOD_LCA_SSH_CONFIG : configFile ,
238
247
GITPOD_LCA_API_PORT : String ( apiPort ) ,
239
248
GITPOD_LCA_AUTO_TUNNEL : String ( false ) ,
240
- GITPOD_LCA_AUTH_REDIRECT_URL : `${ vscode . env . uriScheme } ://${ this . context . extension . id } ${ LocalApp . AUTH_COMPLETE_PATH } ` ,
249
+ GITPOD_LCA_AUTH_REDIRECT_URL : `${ vscode . env . uriScheme } ://${ this . context . extension . id } ${ RemoteConnector . AUTH_COMPLETE_PATH } ` ,
241
250
GITPOD_LCA_VERBOSE : String ( vscode . workspace . getConfiguration ( 'gitpod' ) . get < boolean > ( 'verbose' , false ) ) ,
242
251
GITPOD_LCA_TIMEOUT : String ( vscode . workspace . getConfiguration ( 'gitpod' ) . get < string > ( 'timeout' , '3h' ) )
243
252
}
@@ -379,22 +388,51 @@ export default class LocalApp extends Disposable {
379
388
}
380
389
}
381
390
382
- public async handleUri ( uri : vscode . Uri ) {
383
- if ( uri . path === LocalApp . AUTH_COMPLETE_PATH ) {
384
- this . logger . info ( 'auth completed' ) ;
385
- return ;
391
+ private async getWorkspaceSSHDestination ( workspaceId : string , gitpodHost : string ) : Promise < string > {
392
+ const session = await vscode . authentication . getSession (
393
+ 'gitpod' ,
394
+ [ 'function:getWorkspace' , 'function:getOwnerToken' , 'function:getLoggedInUser' , 'resource:default' ] ,
395
+ { createIfNone : true }
396
+ ) ;
397
+
398
+ const serviceUrl = new URL ( gitpodHost ) ;
399
+
400
+ const workspaceInfo = await withServerApi ( session . accessToken , serviceUrl . toString ( ) , service => service . server . getWorkspace ( workspaceId ) , this . logger ) ;
401
+ if ( ! workspaceInfo . latestInstance ) {
402
+ throw new Error ( 'no_running_instance' ) ;
386
403
}
387
- this . logger . info ( 'open workspace window: ' + uri . toString ( ) ) ;
388
- const params : SSHConnectionParams = JSON . parse ( uri . query ) ;
389
- let resolvedConfig : LocalAppConfig | undefined ;
390
- try {
391
- await vscode . window . withProgress ( {
392
- location : vscode . ProgressLocation . Notification ,
393
- cancellable : true ,
394
- title : `Connecting to Gitpod workspace: ${ params . workspaceId } `
395
- } , async ( _ , token ) => {
404
+
405
+ const workspaceUrl = new URL ( workspaceInfo . latestInstance . ideUrl ) ;
406
+
407
+ const sshHostKeyEndPoint = `https://${ workspaceUrl . host } /_ssh/host_keys` ;
408
+ const sshHostKeyResponse = await fetch ( sshHostKeyEndPoint ) ;
409
+ if ( ! sshHostKeyResponse . ok ) {
410
+ // Gitpod SSH gateway not configured
411
+ throw new Error ( 'no_ssh_gateway' ) ;
412
+ }
413
+
414
+ const ownerToken = await withServerApi ( session . accessToken , serviceUrl . toString ( ) , service => service . server . getOwnerToken ( workspaceId ) , this . logger ) ;
415
+
416
+ const sshDestInfo = {
417
+ user : `${ workspaceId } #${ ownerToken } ` ,
418
+ // See https://github.com/gitpod-io/gitpod/pull/9786 for reasoning about `.ssh` suffix
419
+ hostName : workspaceUrl . host . replace ( workspaceId , `${ workspaceId } .ssh` )
420
+ } ;
421
+
422
+ return Buffer . from ( JSON . stringify ( sshDestInfo ) , 'utf8' ) . toString ( 'hex' ) ;
423
+ }
424
+
425
+ private async getWorkspaceLocalAppSSHDestination ( params : SSHConnectionParams ) : Promise < { localAppSSHDest : string ; localAppSSHConfigPath : string ; } > {
426
+ return vscode . window . withProgress ( {
427
+ location : vscode . ProgressLocation . Notification ,
428
+ cancellable : true ,
429
+ title : `Connecting to Gitpod workspace: ${ params . workspaceId } `
430
+ } , async ( _ , token ) => {
431
+ let localAppLogPath : string | undefined ;
432
+ try {
396
433
const connection = await this . withLocalApp ( params . gitpodHost , ( client , config ) => {
397
- resolvedConfig = config ;
434
+ localAppLogPath = config . logPath ;
435
+
398
436
const request = new ResolveSSHConnectionRequest ( ) ;
399
437
request . setInstanceId ( params . instanceId ) ;
400
438
request . setWorkspaceId ( params . workspaceId ) ;
@@ -403,40 +441,148 @@ export default class LocalApp extends Disposable {
403
441
) ;
404
442
} , token ) ;
405
443
406
- const config = vscode . workspace . getConfiguration ( 'remote.SSH' ) ;
407
- const defaultExtensions = config . get < string [ ] > ( 'defaultExtensions' ) || [ ] ;
408
- if ( defaultExtensions . indexOf ( 'gitpod.gitpod-remote-ssh' ) === - 1 ) {
409
- defaultExtensions . unshift ( 'gitpod.gitpod-remote-ssh' ) ;
410
- await config . update ( 'defaultExtensions' , defaultExtensions , vscode . ConfigurationTarget . Global ) ;
411
- }
412
- // TODO(ak) notify a user about config file changes?
413
- const gitpodConfigFile = connection . getConfigFile ( ) ;
414
- const currentConfigFile = config . get < string > ( 'configFile' ) ;
415
- if ( currentConfigFile === gitpodConfigFile ) {
416
- // invalidate cached SSH targets from the current config file
417
- await config . update ( 'configFile' , undefined , vscode . ConfigurationTarget . Global ) ;
444
+ return {
445
+ localAppSSHDest : connection . getHost ( ) ,
446
+ localAppSSHConfigPath : connection . getConfigFile ( )
447
+ } ;
448
+ } catch ( e ) {
449
+ if ( e instanceof Error && e . message === 'cancelled' ) {
450
+ throw e ;
418
451
}
419
- await config . update ( 'configFile' , gitpodConfigFile , vscode . ConfigurationTarget . Global ) ;
420
- // TODO(ak) ensure that vscode.ssh-remote is installed
421
- await vscode . commands . executeCommand ( 'vscode.openFolder' , vscode . Uri . parse ( `vscode-remote://ssh-remote+${ connection . getHost ( ) } ${ uri . path || '/' } ` ) , {
422
- forceNewWindow : true
423
- } ) ;
424
- } ) ;
452
+
453
+ throw new LocalAppError ( e . message , localAppLogPath ) ;
454
+ }
455
+ } ) ;
456
+ }
457
+
458
+ private async updateRemoteSSHConfig ( usingSSHGateway : boolean , localAppSSHConfigPath : string | undefined ) {
459
+ const remoteSSHconfig = vscode . workspace . getConfiguration ( 'remote.SSH' ) ;
460
+ const defaultExtConfigInfo = remoteSSHconfig . inspect < string [ ] > ( 'defaultExtensions' ) ;
461
+ const defaultExtensions = defaultExtConfigInfo ?. globalValue ?? [ ] ;
462
+ if ( ! defaultExtensions . includes ( 'gitpod.gitpod-remote-ssh' ) ) {
463
+ defaultExtensions . unshift ( 'gitpod.gitpod-remote-ssh' ) ;
464
+ await remoteSSHconfig . update ( 'defaultExtensions' , defaultExtensions , vscode . ConfigurationTarget . Global ) ;
465
+ }
466
+
467
+ const currentConfigFile = remoteSSHconfig . get < string > ( 'configFile' ) ;
468
+ if ( usingSSHGateway ) {
469
+ if ( currentConfigFile ?. includes ( 'gitpod_ssh_config' ) ) {
470
+ await remoteSSHconfig . update ( 'configFile' , undefined , vscode . ConfigurationTarget . Global ) ;
471
+ }
472
+ } else {
473
+ // TODO(ak) notify a user about config file changes?
474
+ if ( currentConfigFile === localAppSSHConfigPath ) {
475
+ // invalidate cached SSH targets from the current config file
476
+ await remoteSSHconfig . update ( 'configFile' , undefined , vscode . ConfigurationTarget . Global ) ;
477
+ }
478
+ await remoteSSHconfig . update ( 'configFile' , localAppSSHConfigPath , vscode . ConfigurationTarget . Global ) ;
479
+ }
480
+ }
481
+
482
+ private async ensureRemoteSSHExtInstalled ( ) : Promise < boolean > {
483
+ const msVscodeRemoteExt = vscode . extensions . getExtension ( 'ms-vscode-remote.remote-ssh' ) ;
484
+ if ( msVscodeRemoteExt ) {
485
+ return true ;
486
+ }
487
+
488
+ const install = 'Install' ;
489
+ const cancel = 'Cancel' ;
490
+ const action = await vscode . window . showInformationMessage ( 'Please install "Remote - SSH" extension to connect to a Gitpod workspace.' , install , cancel ) ;
491
+ if ( action === cancel ) {
492
+ return false ;
493
+ }
494
+
495
+ this . logger . info ( 'Installing "ms-vscode-remote.remote-ssh" extension' ) ;
496
+
497
+ await vscode . commands . executeCommand ( 'extension.open' , 'ms-vscode-remote.remote-ssh' ) ;
498
+ await vscode . commands . executeCommand ( 'workbench.extensions.installExtension' , 'ms-vscode-remote.remote-ssh' ) ;
499
+
500
+ return true ;
501
+ }
502
+
503
+ public async handleUri ( uri : vscode . Uri ) {
504
+ if ( uri . path === RemoteConnector . AUTH_COMPLETE_PATH ) {
505
+ this . logger . info ( 'auth completed' ) ;
506
+ return ;
507
+ }
508
+
509
+ const isRemoteSSHExtInstalled = this . ensureRemoteSSHExtInstalled ( ) ;
510
+ if ( ! isRemoteSSHExtInstalled ) {
511
+ return ;
512
+ }
513
+
514
+ const params : SSHConnectionParams = JSON . parse ( uri . query ) ;
515
+ const gitpodHost = vscode . workspace . getConfiguration ( 'gitpod' ) . get < string > ( 'host' ) ! ;
516
+ if ( new URL ( params . gitpodHost ) . host !== new URL ( gitpodHost ) . host ) {
517
+ const yes = 'Yes' ;
518
+ const cancel = 'Cancel' ;
519
+ const action = await vscode . window . showInformationMessage ( `Trying to connect to a remote workspace in a different Gitpod Host. Continue and update 'gitpod.host' setting to '${ params . gitpodHost } '?` , yes , cancel ) ;
520
+ if ( action === cancel ) {
521
+ return ;
522
+ }
523
+
524
+ await vscode . workspace . getConfiguration ( 'gitpod' ) . update ( 'host' , params . gitpodHost , vscode . ConfigurationTarget . Global ) ;
525
+ this . logger . info ( `Updated 'gitpod.host' setting to '${ params . gitpodHost } ' while trying to connect to a remote workspace` ) ;
526
+ }
527
+
528
+ this . logger . info ( 'Opening remote workspace' , uri . toString ( ) ) ;
529
+
530
+ let sshDestination : string | undefined ;
531
+ try {
532
+ sshDestination = await this . getWorkspaceSSHDestination ( params . workspaceId , params . gitpodHost ) ;
425
533
} catch ( e ) {
426
- const seeLogs = 'See Logs' ;
427
- vscode . window . showErrorMessage ( `Failed to connect to Gitpod workspace ${ params . workspaceId } : ${ e } ` , seeLogs ) . then ( async result => {
428
- if ( result !== seeLogs ) {
429
- return ;
534
+ if ( e instanceof Error && e . message === 'no_ssh_gateway' ) {
535
+ this . logger . error ( 'SSH gateway not configured for this Gitpod Host' , params . gitpodHost ) ;
536
+ // Do nothing and continue execution
537
+ } else if ( e instanceof Error && e . message === 'no_running_instance' ) {
538
+ this . logger . error ( 'No running instance for this workspaceId' , params . workspaceId ) ;
539
+ vscode . window . showErrorMessage ( `Failed to connect to remote workspace: No running instance for '${ params . workspaceId } '` ) ;
540
+ return ;
541
+ } else {
542
+ this . logger . error ( `Failed to connect to remote workspace ${ params . workspaceId } ` , e ) ;
543
+ const seeLogs = 'See Logs' ;
544
+ const action = await vscode . window . showErrorMessage ( `Failed to connect to remote workspace ${ params . workspaceId } ` , seeLogs ) ;
545
+ if ( action === seeLogs ) {
546
+ this . logger . show ( ) ;
430
547
}
431
- this . logger . show ( ) ;
432
- if ( resolvedConfig ) {
433
- const document = await vscode . workspace . openTextDocument ( vscode . Uri . file ( resolvedConfig . logPath ) ) ;
434
- vscode . window . showTextDocument ( document ) ;
548
+ return ;
549
+ }
550
+ }
551
+
552
+ const usingSSHGateway = ! ! sshDestination ;
553
+ let localAppSSHConfigPath : string | undefined ;
554
+ if ( ! usingSSHGateway ) {
555
+ vscode . window . showWarningMessage ( `${ params . gitpodHost } does not support [direct SSH access](https://github.com/gitpod-io/gitpod/blob/main/install/installer/docs/workspace-ssh-access.md), connecting via the deprecated SSH tunnel over WebSocket.` ) ;
556
+ try {
557
+ const localAppDestData = await this . getWorkspaceLocalAppSSHDestination ( params ) ;
558
+ sshDestination = localAppDestData . localAppSSHDest ;
559
+ localAppSSHConfigPath = localAppDestData . localAppSSHConfigPath ;
560
+ } catch ( e ) {
561
+ this . logger . error ( `Failed to connect to remote workspace ${ params . workspaceId } ` , e ) ;
562
+ if ( e instanceof LocalAppError ) {
563
+ const seeLogs = 'See Logs' ;
564
+ const action = await vscode . window . showErrorMessage ( `Failed to connect to remote workspace ${ params . workspaceId } ` , seeLogs ) ;
565
+ if ( action === seeLogs ) {
566
+ this . logger . show ( ) ;
567
+ if ( e . logPath ) {
568
+ const document = await vscode . workspace . openTextDocument ( vscode . Uri . file ( e . logPath ) ) ;
569
+ vscode . window . showTextDocument ( document ) ;
570
+ }
571
+ }
572
+ } else {
573
+ // Do nothing, user cancelled the operation
435
574
}
436
- } ) ;
437
- this . logger . error ( `failed to open uri: ${ e } ` ) ;
438
- throw e ;
575
+ return ;
576
+ }
439
577
}
578
+
579
+ await this . updateRemoteSSHConfig ( usingSSHGateway , localAppSSHConfigPath ) ;
580
+
581
+ vscode . commands . executeCommand (
582
+ 'vscode.openFolder' ,
583
+ vscode . Uri . parse ( `vscode-remote://ssh-remote+${ sshDestination } ${ uri . path || '/' } ` ) ,
584
+ { forceNewWindow : true }
585
+ ) ;
440
586
}
441
587
442
588
public async autoTunnelCommand ( gitpodHost : string , instanceId : string , enabled : boolean ) {
0 commit comments