Skip to content

Commit 7ec87c9

Browse files
authored
Add check for missing SSH key (#1)
1 parent 1fc0514 commit 7ec87c9

File tree

5 files changed

+134
-15
lines changed

5 files changed

+134
-15
lines changed

src/common/files.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as fs from 'fs';
6+
7+
export async function exists(path: string) {
8+
try {
9+
await fs.promises.access(path);
10+
return true;
11+
} catch {
12+
return false;
13+
}
14+
}

src/extension.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Copyright (c) Gitpod. All rights reserved.
33
*--------------------------------------------------------------------------------------------*/
44

5+
import * as os from 'os';
56
import * as vscode from 'vscode';
67
import Log from './common/logger';
78
import GitpodAuthenticationProvider from './authentication';
@@ -17,9 +18,11 @@ const FIRST_INSTALL_KEY = 'gitpod-desktop.firstInstall';
1718
let telemetry: TelemetryReporter;
1819

1920
export async function activate(context: vscode.ExtensionContext) {
21+
const packageJSON = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON;
22+
2023
const logger = new Log('Gitpod');
24+
logger.info(`${EXTENSION_ID}/${packageJSON.version} (${os.release()} ${os.platform()} ${os.arch()}) vscode/${vscode.version} (${vscode.env.appName})`);
2125

22-
const packageJSON = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON;
2326
telemetry = new TelemetryReporter(EXTENSION_ID, packageJSON.version, packageJSON.segmentKey);
2427

2528
/* Gitpod settings sync */

src/remoteConnector.ts

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import Log from './common/logger';
1515
import { Disposable } from './common/dispose';
1616
import { withServerApi } from './internalApi';
1717
import TelemetryReporter from './telemetryReporter';
18-
import { addHostToHostFile, checkNewHostInHostkeys } from './common/hostfile';
18+
import { addHostToHostFile, checkNewHostInHostkeys } from './ssh/hostfile';
19+
import { checkDefaultIdentityFiles } from './ssh/identityFiles';
1920

2021
interface SSHConnectionParams {
2122
workspaceId: string;
@@ -413,7 +414,7 @@ export default class RemoteConnector extends Disposable {
413414
}
414415
}
415416

416-
private async getWorkspaceSSHDestination(workspaceId: string, gitpodHost: string): Promise<string> {
417+
private async getWorkspaceSSHDestination(workspaceId: string, gitpodHost: string): Promise<{ destination: string; password?: string }> {
417418
const session = await vscode.authentication.getSession(
418419
'gitpod',
419420
['function:getWorkspace', 'function:getOwnerToken', 'function:getLoggedInUser', 'resource:default'],
@@ -440,8 +441,9 @@ export default class RemoteConnector extends Disposable {
440441

441442
const ownerToken = await withServerApi(session.accessToken, serviceUrl.toString(), service => service.server.getOwnerToken(workspaceId), this.logger);
442443

444+
let password: string | undefined = ownerToken;
443445
const sshDestInfo = {
444-
user: `${workspaceId}#${ownerToken}`,
446+
user: workspaceId,
445447
// See https://github.com/gitpod-io/gitpod/pull/9786 for reasoning about `.ssh` suffix
446448
hostName: workspaceUrl.host.replace(workspaceId, `${workspaceId}.ssh`)
447449
};
@@ -487,10 +489,45 @@ export default class RemoteConnector extends Disposable {
487489
this.logger.info(`'${sshDestInfo.hostName}' host added to known_hosts file`);
488490
}
489491
} catch (e) {
490-
this.logger.error(`Couldn't write '${sshDestInfo.hostName}' host to known_hosts file`, e);
492+
this.logger.error(`Couldn't write '${sshDestInfo.hostName}' host to known_hosts file:`, e);
491493
}
492494

493-
return Buffer.from(JSON.stringify(sshDestInfo), 'utf8').toString('hex');
495+
const identityFiles = await checkDefaultIdentityFiles();
496+
this.logger.trace(`Default identity files:`, identityFiles.length ? identityFiles.toString() : 'None');
497+
498+
// Commented this for now as `checkDefaultIdentityFiles` seems enough
499+
// Connect to the OpenSSH agent and check for registered keys
500+
// let sshKeys: ParsedKey[] | undefined;
501+
// try {
502+
// if (process.env['SSH_AUTH_SOCK']) {
503+
// sshKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
504+
// const sshAgent = new OpenSSHAgent(process.env['SSH_AUTH_SOCK']!);
505+
// sshAgent.getIdentities((err, publicKeys) => {
506+
// if (err) {
507+
// reject(err);
508+
// } else {
509+
// resolve(publicKeys!);
510+
// }
511+
// });
512+
// });
513+
// } else {
514+
// this.logger.error(`'SSH_AUTH_SOCK' env variable not defined, cannot connect to OpenSSH agent`);
515+
// }
516+
// } catch (e) {
517+
// this.logger.error(`Couldn't get identities from OpenSSH agent`, e);
518+
// }
519+
520+
// If user has default identity files or agent have registered keys,
521+
// then use public key authentication
522+
if (identityFiles.length) {
523+
sshDestInfo.user = `${workspaceId}#${ownerToken}`;
524+
password = undefined;
525+
}
526+
527+
return {
528+
destination: Buffer.from(JSON.stringify(sshDestInfo), 'utf8').toString('hex'),
529+
password
530+
};
494531
}
495532

496533
private async getWorkspaceLocalAppSSHDestination(params: SSHConnectionParams): Promise<{ localAppSSHDest: string; localAppSSHConfigPath: string }> {
@@ -571,13 +608,31 @@ export default class RemoteConnector extends Disposable {
571608
return true;
572609
}
573610

611+
private async showSSHPasswordModal(password: string) {
612+
const maskedPassword = '•'.repeat(password.length - 3) + password.substring(password.length - 3);
613+
614+
const copy = 'Copy';
615+
const configureSSH = 'Configure SSH';
616+
const action = await vscode.window.showInformationMessage(`An SSH key is required for passwordless authentication.\nAlternatively, copy and use this password: ${maskedPassword}`, { modal: true }, copy, configureSSH);
617+
if (action === copy) {
618+
await vscode.env.clipboard.writeText(password);
619+
return;
620+
}
621+
if (action === configureSSH) {
622+
await vscode.env.openExternal(vscode.Uri.parse('https://www.gitpod.io/docs/configure/ssh#create-an-ssh-key'));
623+
throw new Error(`SSH password modal dialog, ${configureSSH}`);
624+
}
625+
626+
throw new Error('SSH password modal dialog, Canceled');
627+
}
628+
574629
public async handleUri(uri: vscode.Uri) {
575630
if (uri.path === RemoteConnector.AUTH_COMPLETE_PATH) {
576631
this.logger.info('auth completed');
577632
return;
578633
}
579634

580-
const isRemoteSSHExtInstalled = this.ensureRemoteSSHExtInstalled();
635+
const isRemoteSSHExtInstalled = await this.ensureRemoteSSHExtInstalled();
581636
if (!isRemoteSSHExtInstalled) {
582637
return;
583638
}
@@ -605,24 +660,29 @@ export default class RemoteConnector extends Disposable {
605660
try {
606661
this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'gateway', status: 'connecting', ...params });
607662

608-
sshDestination = await this.getWorkspaceSSHDestination(params.workspaceId, params.gitpodHost);
663+
const { destination, password } = await this.getWorkspaceSSHDestination(params.workspaceId, params.gitpodHost);
664+
sshDestination = destination;
665+
666+
if (password) {
667+
await this.showSSHPasswordModal(password);
668+
}
609669

610670
this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'gateway', status: 'connected', ...params });
611671
} catch (e) {
612672
this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'gateway', status: 'failed', reason: e.toString(), ...params });
613673
if (e instanceof NoSSHGatewayError) {
614-
this.logger.error('No SSH gateway', e);
674+
this.logger.error('No SSH gateway:', e);
615675
vscode.window.showWarningMessage(`${e.host} 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.`);
616676
// Do nothing and continue execution
617677
} else if (e instanceof NoRunningInstanceError) {
618-
this.logger.error('No Running instance', e);
678+
this.logger.error('No Running instance:', e);
619679
vscode.window.showErrorMessage(`Failed to connect to ${e.workspaceId} Gitpod workspace: workspace not running`);
620680
return;
621681
} else {
622682
if (e instanceof SSHError) {
623-
this.logger.error('SSH test connection error', e);
683+
this.logger.error('SSH test connection error:', e);
624684
} else {
625-
this.logger.error(`Failed to connect to ${params.workspaceId} Gitpod workspace`, e);
685+
this.logger.error(`Failed to connect to ${params.workspaceId} Gitpod workspace:`, e);
626686
}
627687
const seeLogs = 'See Logs';
628688
const showTroubleshooting = 'Show Troubleshooting';
@@ -649,7 +709,7 @@ export default class RemoteConnector extends Disposable {
649709

650710
this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'local-app', status: 'connected', ...params });
651711
} catch (e) {
652-
this.logger.error(`Failed to connect ${params.workspaceId} Gitpod workspace`, e);
712+
this.logger.error(`Failed to connect ${params.workspaceId} Gitpod workspace:`, e);
653713
if (e instanceof LocalAppError) {
654714
this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'local-app', status: 'failed', reason: e.toString(), ...params });
655715
const seeLogs = 'See Logs';
@@ -692,7 +752,7 @@ export default class RemoteConnector extends Disposable {
692752
);
693753
});
694754
} catch (e) {
695-
this.logger.error('failed to disable auto tunneling', e);
755+
this.logger.error('Failed to disable auto tunneling:', e);
696756
}
697757
}
698758
}

src/common/hostfile.ts renamed to src/ssh/hostfile.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import * as os from 'os';
66
import * as fs from 'fs';
77
import * as path from 'path';
88
import * as crypto from 'crypto';
9+
import { exists as folderExists } from '../common/files';
910

10-
const KNOW_HOST_FILE = path.join(os.homedir(), '.ssh', 'known_hosts');
11+
const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh');
12+
const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts');
1113
const HASH_MAGIC = '|1|';
1214
const HASH_DELIM = '|';
1315

@@ -32,6 +34,10 @@ export async function checkNewHostInHostkeys(host: string): Promise<boolean> {
3234
}
3335

3436
export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise<void> {
37+
if (!folderExists(PATH_SSH_USER_DIR)) {
38+
await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700);
39+
}
40+
3541
const salt = crypto.randomBytes(20);
3642
const hostHash = crypto.createHmac('sha1', salt).update(host).digest();
3743

src/ssh/identityFiles.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as os from 'os';
6+
import * as path from 'path';
7+
import { exists as fileExists } from '../common/files';
8+
9+
const homeDir = os.homedir();
10+
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
11+
const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa');
12+
const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa');
13+
const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519');
14+
const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
15+
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
16+
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
17+
18+
export async function checkDefaultIdentityFiles(): Promise<string[]> {
19+
const files = [
20+
PATH_SSH_CLIENT_ID_DSA,
21+
PATH_SSH_CLIENT_ID_ECDSA,
22+
PATH_SSH_CLIENT_ID_RSA,
23+
PATH_SSH_CLIENT_ID_ED25519,
24+
PATH_SSH_CLIENT_ID_XMSS,
25+
PATH_SSH_CLIENT_ID_ECDSA_SK,
26+
PATH_SSH_CLIENT_ID_ED25519_SK
27+
];
28+
29+
const result: string[] = [];
30+
for (const file of files) {
31+
if (await fileExists(file)) {
32+
result.push(file);
33+
}
34+
}
35+
return result;
36+
}

0 commit comments

Comments
 (0)