Skip to content

Commit 2a0b515

Browse files
committed
[gitpod-desktop] Connect using ssh gateway
1 parent 8df396b commit 2a0b515

File tree

7 files changed

+321
-171
lines changed

7 files changed

+321
-171
lines changed

extensions/gitpod-web/src/util/zip.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ function extractZip(zipfile: ZipFile, targetPath: string, options: IOptions, tok
160160

161161
function openZip(zipFile: string, lazy: boolean = false): Promise<ZipFile> {
162162
return new Promise<ZipFile>((resolve, reject) => {
163-
_openZip(zipFile, lazy ? { lazyEntries: true } : undefined!, (error?: Error, zipfile?: ZipFile) => {
163+
_openZip(zipFile, lazy ? { lazyEntries: true } : undefined!, (error: Error | null, zipfile?: ZipFile) => {
164164
if (error) {
165165
reject(toExtractError(error));
166166
} else {
@@ -172,7 +172,7 @@ function openZip(zipFile: string, lazy: boolean = false): Promise<ZipFile> {
172172

173173
function openZipStream(zipFile: ZipFile, entry: Entry): Promise<Readable> {
174174
return new Promise<Readable>((resolve, reject) => {
175-
zipFile.openReadStream(entry, (error?: Error, stream?: Readable) => {
175+
zipFile.openReadStream(entry, (error: Error | null, stream?: Readable) => {
176176
if (error) {
177177
reject(toExtractError(error));
178178
} else {

extensions/gitpod/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,5 @@
108108
"tmp": "^0.2.1",
109109
"uuid": "8.1.0",
110110
"yazl": "^2.5.1"
111-
},
112-
"extensionDependencies": [
113-
"ms-vscode-remote.remote-ssh"
114-
]
111+
}
115112
}

extensions/gitpod/src/extension.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import * as vscode from 'vscode';
66
import Log from './common/logger';
77
import GitpodAuthenticationProvider from './authentication';
8-
import LocalApp from './localApp';
8+
import RemoteConnector from './remoteConnector';
99
import { enableSettingsSync, updateSyncContext } from './settingsSync';
1010
import { GitpodServer } from './gitpodServer';
1111
import TelemetryReporter from './telemetryReporter';
@@ -68,16 +68,16 @@ export async function activate(context: vscode.ExtensionContext) {
6868
}));
6969

7070
const authProvider = new GitpodAuthenticationProvider(context, logger, telemetry);
71-
const localApp = new LocalApp(context, logger);
71+
const remoteConnector = new RemoteConnector(context, logger);
7272
context.subscriptions.push(authProvider);
73-
context.subscriptions.push(localApp);
73+
context.subscriptions.push(remoteConnector);
7474
context.subscriptions.push(vscode.window.registerUriHandler({
7575
handleUri(uri: vscode.Uri) {
7676
// logger.trace('Handling Uri...', uri.toString());
7777
if (uri.path === GitpodServer.AUTH_COMPLETE_PATH) {
7878
authProvider.handleUri(uri);
7979
} else {
80-
localApp.handleUri(uri);
80+
remoteConnector.handleUri(uri);
8181
}
8282
}
8383
}));

extensions/gitpod/src/internalApi.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ReconnectingWebSocket from 'reconnecting-websocket';
1010
import * as vscode from 'vscode';
1111
import Log from './common/logger';
1212

13-
type UsedGitpodFunction = ['getLoggedInUser', 'getGitpodTokenScopes'];
13+
type UsedGitpodFunction = ['getLoggedInUser', 'getGitpodTokenScopes', 'getWorkspace', 'getOwnerToken'];
1414
type Union<Tuple extends any[], Union = never> = Tuple[number] | Union;
1515
export type GitpodConnection = Omit<GitpodServiceImpl<GitpodClient, GitpodServer>, 'server'> & {
1616
server: Pick<GitpodServer, Union<UsedGitpodFunction>>;
@@ -28,6 +28,8 @@ class GitpodServerApi extends vscode.Disposable {
2828
constructor(accessToken: string, serviceUrl: string, private readonly logger: Log) {
2929
super(() => this.internalDispose());
3030

31+
serviceUrl = serviceUrl.replace(/\/$/, '');
32+
3133
const factory = new JsonRpcProxyFactory<GitpodServer>();
3234
this.service = new GitpodServiceImpl<GitpodClient, GitpodServer>(factory.createProxy());
3335

extensions/gitpod/src/localApp.ts renamed to extensions/gitpod/src/remoteConnector.ts

Lines changed: 193 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as path from 'path';
1212
import * as vscode from 'vscode';
1313
import Log from './common/logger';
1414
import { Disposable } from './common/dispose';
15+
import { withServerApi } from './internalApi';
1516

1617
interface SSHConnectionParams {
1718
workspaceId: string;
@@ -63,10 +64,18 @@ function checkRunning(pid: number): true | Error {
6364
}
6465
}
6566

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 {
6776

6877
public static AUTH_COMPLETE_PATH = '/auth-complete';
69-
private static lockCount = 0;
78+
private static LOCK_COUNT = 0;
7079

7180
constructor(private readonly context: vscode.ExtensionContext, private readonly logger: Log) {
7281
super();
@@ -100,7 +109,7 @@ export default class LocalApp extends Disposable {
100109
private async withLock<T>(lockName: string, op: (token: vscode.CancellationToken) => Promise<T>, timeout: number, token?: vscode.CancellationToken): Promise<T> {
101110
this.logger.info(`acquiring lock: ${lockName}`);
102111
const lockKey = lockPrefix + lockName;
103-
const value = vscode.env.sessionId + '/' + LocalApp.lockCount++;
112+
const value = vscode.env.sessionId + '/' + RemoteConnector.LOCK_COUNT++;
104113
let currentLock: Lock | undefined;
105114
let deadline: number | undefined;
106115
const updateTimeout = 150;
@@ -237,7 +246,7 @@ export default class LocalApp extends Disposable {
237246
GITPOD_LCA_SSH_CONFIG: configFile,
238247
GITPOD_LCA_API_PORT: String(apiPort),
239248
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}`,
241250
GITPOD_LCA_VERBOSE: String(vscode.workspace.getConfiguration('gitpod').get<boolean>('verbose', false)),
242251
GITPOD_LCA_TIMEOUT: String(vscode.workspace.getConfiguration('gitpod').get<string>('timeout', '3h'))
243252
}
@@ -379,22 +388,51 @@ export default class LocalApp extends Disposable {
379388
}
380389
}
381390

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');
386403
}
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 {
396433
const connection = await this.withLocalApp(params.gitpodHost, (client, config) => {
397-
resolvedConfig = config;
434+
localAppLogPath = config.logPath;
435+
398436
const request = new ResolveSSHConnectionRequest();
399437
request.setInstanceId(params.instanceId);
400438
request.setWorkspaceId(params.workspaceId);
@@ -403,40 +441,148 @@ export default class LocalApp extends Disposable {
403441
);
404442
}, token);
405443

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;
418451
}
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);
425533
} 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();
430547
}
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
435574
}
436-
});
437-
this.logger.error(`failed to open uri: ${e}`);
438-
throw e;
575+
return;
576+
}
439577
}
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+
);
440586
}
441587

442588
public async autoTunnelCommand(gitpodHost: string, instanceId: string, enabled: boolean) {

extensions/gitpod/src/settingsSync.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ function getGitpodSyncProviderConfig(serviceUrl: string): ConfigurationSyncStore
2525
scopes: [
2626
'function:accessCodeSyncStorage',
2727
'function:getLoggedInUser',
28-
'function:getGitpodTokenScopes',
2928
'resource:default'
3029
]
3130
}

0 commit comments

Comments
 (0)