Skip to content

Commit c7fabb3

Browse files
committed
Add tunnel support
1 parent 4d51ad6 commit c7fabb3

File tree

6 files changed

+762
-145
lines changed

6 files changed

+762
-145
lines changed

src/remoteConnector.ts

Lines changed: 12 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import * as fs from 'fs';
1515
import * as http from 'http';
1616
import * as net from 'net';
1717
import * as crypto from 'crypto';
18-
import { Client as sshClient, OpenSSHAgent, utils as sshUtils } from 'ssh2';
18+
import { utils as sshUtils } from 'ssh2';
1919
import { ParsedKey } from 'ssh2-streams';
2020
import * as tmp from 'tmp';
2121
import * as path from 'path';
@@ -25,12 +25,9 @@ import { Disposable } from './common/dispose';
2525
import { withServerApi } from './internalApi';
2626
import TelemetryReporter from './telemetryReporter';
2727
import { addHostToHostFile, checkNewHostInHostkeys } from './ssh/hostfile';
28-
import { DEFAULT_IDENTITY_FILES } from './ssh/identityFiles';
2928
import { HeartbeatManager } from './heartbeat';
3029
import { getGitpodVersion, GitpodVersion, isFeatureSupported, isOauthInspectSupported, ScopeFeature } from './featureSupport';
3130
import SSHConfiguration from './ssh/sshConfig';
32-
import { isWindows } from './common/platform';
33-
import { untildify } from './common/files';
3431
import { ExperimentalSettings, isUserOverrideSetting } from './experiments';
3532
import { ISyncExtension, NoSettingsSyncSession, NoSyncStoreError, parseSyncData, SettingsSync, SyncResource } from './settingsSync';
3633
import { retry } from './common/async';
@@ -39,6 +36,8 @@ import { NotificationService } from './notification';
3936
import { UserFlowTelemetry } from './common/telemetry';
4037
import { GitpodPublicApi } from './publicApi';
4138
import { SSHKey } from '@gitpod/public-api/lib/gitpod/experimental/v1/user_pb';
39+
import { getAgentSock, SSHError, testSSHConnection } from './sshTestConnection';
40+
import { gatherIdentityFiles } from './ssh/identityFiles';
4241

4342
interface SSHConnectionParams {
4443
workspaceId: string;
@@ -100,15 +99,6 @@ class LocalAppError extends Error {
10099
}
101100
}
102101

103-
class SSHError extends Error {
104-
constructor(cause: Error) {
105-
super();
106-
this.name = cause.name;
107-
this.message = cause.message;
108-
this.stack = cause.stack;
109-
}
110-
}
111-
112102
class NoRunningInstanceError extends Error {
113103
constructor(readonly workspaceId: string) {
114104
super(`Failed to connect to ${workspaceId} Gitpod workspace, workspace not running`);
@@ -453,86 +443,6 @@ export default class RemoteConnector extends Disposable {
453443
}
454444
}
455445

456-
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
457-
private async getIdentityKeys(hostConfig: Record<string, string>) {
458-
const identityFiles: string[] = ((hostConfig['IdentityFile'] as unknown as string[]) || []).map(untildify).map(i => i.replace(/\.pub$/, ''));
459-
if (identityFiles.length === 0) {
460-
identityFiles.push(...DEFAULT_IDENTITY_FILES);
461-
}
462-
463-
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async path => fs.promises.readFile(path + '.pub')));
464-
const fileKeys = identityFileContentsResult.map((result, i) => {
465-
if (result.status === 'rejected') {
466-
return undefined;
467-
}
468-
469-
const parsedResult = sshUtils.parseKey(result.value);
470-
if (parsedResult instanceof Error || !parsedResult) {
471-
this.logger.error(`Error while parsing SSH public key ${identityFiles[i] + '.pub'}:`, parsedResult);
472-
return undefined;
473-
}
474-
475-
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
476-
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
477-
478-
return {
479-
filename: identityFiles[i],
480-
parsedKey,
481-
fingerprint
482-
};
483-
}).filter(<T>(v: T | undefined): v is T => !!v);
484-
485-
let sshAgentParsedKeys: ParsedKey[] = [];
486-
try {
487-
let sshAgentSock = isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : (hostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK']);
488-
if (!sshAgentSock) {
489-
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
490-
}
491-
sshAgentSock = untildify(sshAgentSock);
492-
493-
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
494-
const sshAgent = new OpenSSHAgent(sshAgentSock!);
495-
sshAgent.getIdentities((err, publicKeys) => {
496-
if (err) {
497-
reject(err);
498-
} else {
499-
resolve(publicKeys || []);
500-
}
501-
});
502-
});
503-
} catch (e) {
504-
this.logger.error(`Couldn't get identities from OpenSSH agent`, e);
505-
}
506-
507-
const sshAgentKeys = sshAgentParsedKeys.map(parsedKey => {
508-
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
509-
return {
510-
filename: parsedKey.comment,
511-
parsedKey,
512-
fingerprint
513-
};
514-
});
515-
516-
const identitiesOnly = (hostConfig['IdentitiesOnly'] || '').toLowerCase() === 'yes';
517-
const agentKeys: { filename: string; parsedKey: ParsedKey; fingerprint: string }[] = [];
518-
const preferredIdentityKeys: { filename: string; parsedKey: ParsedKey; fingerprint: string }[] = [];
519-
for (const agentKey of sshAgentKeys) {
520-
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
521-
if (foundIdx >= 0) {
522-
preferredIdentityKeys.push(fileKeys[foundIdx]);
523-
fileKeys.splice(foundIdx, 1);
524-
} else if (!identitiesOnly) {
525-
agentKeys.push(agentKey);
526-
}
527-
}
528-
preferredIdentityKeys.push(...agentKeys);
529-
preferredIdentityKeys.push(...fileKeys);
530-
531-
this.logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
532-
533-
return preferredIdentityKeys;
534-
}
535-
536446
private async getWorkspaceSSHDestination(session: vscode.AuthenticationSession, { workspaceId, gitpodHost, debugWorkspace }: SSHConnectionParams): Promise<{ destination: string; password?: string }> {
537447
const serviceUrl = new URL(gitpodHost);
538448
const sshKeysSupported = session.scopes.includes(ScopeFeature.SSHPublicKeys);
@@ -571,35 +481,14 @@ const sshHostKeys = (await sshHostKeyResponse.json()) as { type: string; host_ke
571481
}
572482
const sshDestInfo = { user, hostName };
573483

574-
let verifiedHostKey: Buffer | undefined;
575-
// Test ssh connection first
576-
await new Promise<void>((resolve, reject) => {
577-
const conn = new sshClient();
578-
conn.on('ready', () => {
579-
conn.end();
580-
resolve();
581-
}).on('error', err => {
582-
reject(new SSHError(err));
583-
}).connect({
584-
host: sshDestInfo.hostName,
585-
username: sshDestInfo.user,
586-
readyTimeout: 40000,
587-
authHandler(_methodsLeft, _partialSuccess, _callback) {
588-
return {
589-
type: 'password',
590-
username: user,
591-
password: ownerToken,
592-
};
593-
},
594-
hostVerifier(hostKey) {
595-
// We didn't specify `hostHash` so `hashedKey` is a Buffer object
596-
verifiedHostKey = (hostKey as any as Buffer);
597-
const encodedKey = verifiedHostKey.toString('base64');
598-
return sshHostKeys.some(keyData => keyData.host_key === encodedKey);
599-
}
600-
});
601-
});
602-
this.logger.info(`SSH test connection to '${sshDestInfo.hostName}' host successful`);
484+
const sshConfiguration = await SSHConfiguration.loadFromFS();
485+
486+
const verifiedHostKey = await testSSHConnection({
487+
host: sshDestInfo.hostName,
488+
username: sshDestInfo.user,
489+
readyTimeout: 40000,
490+
password: ownerToken
491+
}, sshHostKeys, sshConfiguration, this.logger);
603492

604493
// SSH connection successful, write host to known_hosts
605494
try {
@@ -616,10 +505,8 @@ const sshHostKeys = (await sshHostKeyResponse.json()) as { type: string; host_ke
616505
this.logger.error(`Couldn't write '${sshDestInfo.hostName}' host to known_hosts file:`, e);
617506
}
618507

619-
const sshConfiguration = await SSHConfiguration.loadFromFS();
620508
const hostConfiguration = sshConfiguration.getHostConfiguration(sshDestInfo.hostName);
621-
622-
let identityKeys = await this.getIdentityKeys(hostConfiguration);
509+
let identityKeys = await gatherIdentityFiles([], getAgentSock(hostConfiguration), false, this.logger);
623510

624511
if (registeredSSHKeys) {
625512
const registeredKeys = this.publicApi

src/ssh/identityFiles.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import * as fs from 'fs';
67
import * as os from 'os';
78
import * as path from 'path';
9+
import * as crypto from 'crypto';
10+
import { ParsedKey } from 'ssh2-streams';
11+
import * as ssh2 from 'ssh2';
12+
import { untildify, exists as fileExists } from '../common/files';
13+
import Log from '../common/logger';
814

915
const homeDir = os.homedir();
1016
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
@@ -15,7 +21,7 @@ const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
1521
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
1622
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
1723

18-
export const DEFAULT_IDENTITY_FILES: string[] = [
24+
const DEFAULT_IDENTITY_FILES: string[] = [
1925
PATH_SSH_CLIENT_ID_RSA,
2026
PATH_SSH_CLIENT_ID_ECDSA,
2127
PATH_SSH_CLIENT_ID_ECDSA_SK,
@@ -24,3 +30,92 @@ export const DEFAULT_IDENTITY_FILES: string[] = [
2430
PATH_SSH_CLIENT_ID_XMSS,
2531
PATH_SSH_CLIENT_ID_DSA,
2632
];
33+
34+
export interface SSHKey {
35+
filename: string;
36+
parsedKey: ParsedKey;
37+
fingerprint: string;
38+
agentSupport?: boolean;
39+
isPrivate?: boolean;
40+
}
41+
42+
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
43+
export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) {
44+
identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, ''));
45+
if (identityFiles.length === 0) {
46+
identityFiles.push(...DEFAULT_IDENTITY_FILES);
47+
}
48+
49+
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
50+
keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;
51+
return fs.promises.readFile(keyPath);
52+
}));
53+
const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => {
54+
if (result.status === 'rejected') {
55+
return undefined;
56+
}
57+
58+
const parsedResult = ssh2.utils.parseKey(result.value);
59+
if (parsedResult instanceof Error || !parsedResult) {
60+
logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult);
61+
return undefined;
62+
}
63+
64+
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
65+
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
66+
67+
return {
68+
filename: identityFiles[i],
69+
parsedKey,
70+
fingerprint
71+
};
72+
}).filter(<T>(v: T | undefined): v is T => !!v);
73+
74+
let sshAgentParsedKeys: ParsedKey[] = [];
75+
try {
76+
if (!sshAgentSock) {
77+
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
78+
}
79+
80+
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
81+
const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock);
82+
sshAgent.getIdentities((err, publicKeys) => {
83+
if (err) {
84+
reject(err);
85+
} else {
86+
resolve(publicKeys || []);
87+
}
88+
});
89+
});
90+
} catch (e) {
91+
logger.error(`Couldn't get identities from OpenSSH agent`, e);
92+
}
93+
94+
const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => {
95+
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
96+
return {
97+
filename: parsedKey.comment,
98+
parsedKey,
99+
fingerprint,
100+
agentSupport: true
101+
};
102+
});
103+
104+
const agentKeys: SSHKey[] = [];
105+
const preferredIdentityKeys: SSHKey[] = [];
106+
for (const agentKey of sshAgentKeys) {
107+
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
108+
if (foundIdx >= 0) {
109+
preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true });
110+
fileKeys.splice(foundIdx, 1);
111+
} else if (!identitiesOnly) {
112+
agentKeys.push(agentKey);
113+
}
114+
}
115+
preferredIdentityKeys.push(...agentKeys);
116+
preferredIdentityKeys.push(...fileKeys);
117+
118+
logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
119+
120+
return preferredIdentityKeys;
121+
}

0 commit comments

Comments
 (0)