Skip to content

Commit 7bfc9c1

Browse files
authored
Implement local ssh daemon (#36)
1 parent ff65e79 commit 7bfc9c1

31 files changed

+3954
-90
lines changed

package.json

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "Gitpod Support",
55
"publisher": "gitpod",
66
"version": "0.0.98",
7+
"daemonVersion": "0.0.1",
78
"license": "MIT",
89
"icon": "resources/gitpod.png",
910
"repository": {
@@ -41,7 +42,8 @@
4142
"onCommand:gitpod.installLocalExtensions",
4243
"onAuthenticationRequest:gitpod",
4344
"onUri",
44-
"onStartupFinished"
45+
"onStartupFinished",
46+
"onResolveRemoteAuthority:ssh-remote"
4547
],
4648
"contributes": {
4749
"authentication": [
@@ -71,6 +73,16 @@
7173
"description": "Use the local companion app to connect to a remote workspace.\nWarning: Connecting to a remote workspace using local companion app will be removed in the near future.",
7274
"default": false,
7375
"scope": "application"
76+
},
77+
"gitpod.lsshPort": {
78+
"type": "number",
79+
"description": "The port to use for the local SSH server.",
80+
"scope": "application"
81+
},
82+
"gitpod.lsshIpcPort": {
83+
"type": "number",
84+
"description": "The port to use for the local SSH ipc server.",
85+
"scope": "application"
7486
}
7587
}
7688
},
@@ -137,9 +149,10 @@
137149
"webpack": "webpack --mode development",
138150
"compile": "tsc -b",
139151
"watch": "tsc -b -w",
140-
"package": "npx vsce package --yarn",
152+
"package": "vsce package --yarn",
141153
"lint": "eslint . --ext .ts",
142-
"test": "mocha -u tdd"
154+
"test": "mocha -u tdd",
155+
"proto-gen": "bash ./src/proto/generate.sh"
143156
},
144157
"devDependencies": {
145158
"@types/google-protobuf": "^3.7.4",
@@ -148,20 +161,24 @@
148161
"@types/node": "16.x",
149162
"@types/semver": "^7.3.10",
150163
"@types/ssh2": "^0.5.52",
164+
"@types/sshpk": "^1.17.1",
151165
"@types/tmp": "^0.2.1",
152166
"@types/uuid": "8.0.0",
153167
"@types/vscode": "1.74.0",
154168
"@types/webpack": "^5.28.0",
155-
"@types/ws": "^7.4.7",
169+
"@types/ws": "^8.5.4",
156170
"@types/yazl": "^2.4.2",
157171
"@typescript-eslint/eslint-plugin": "^5.19.0",
158172
"@typescript-eslint/parser": "^5.19.0",
173+
"@vscode/vsce": "^2.18.0",
159174
"eslint": "^8.13.0",
160175
"eslint-plugin-header": "3.1.1",
161176
"eslint-plugin-jsdoc": "^19.1.0",
177+
"grpc-tools": "^1.12.4",
162178
"minimist": "^1.2.6",
163179
"mocha": "^10.0.0",
164180
"ts-loader": "^9.2.7",
181+
"ts-proto": "^1.140.0",
165182
"typescript": "^4.6.3",
166183
"webpack": "^5.42.0",
167184
"webpack-cli": "^4.7.2"
@@ -171,21 +188,31 @@
171188
"@gitpod/gitpod-protocol": "main",
172189
"@gitpod/local-app-api-grpcweb": "main",
173190
"@gitpod/public-api": "^0.1.5-main.6530",
191+
"@gitpod/supervisor-api-grpc": "0.1.5-main.6711",
192+
"@grpc/grpc-js": "^1.8.8",
174193
"@improbable-eng/grpc-web-node-http-transport": "^0.14.0",
194+
"@microsoft/dev-tunnels-ssh": "^3.11.2",
195+
"@microsoft/dev-tunnels-ssh-keys": "^3.11.2",
196+
"@microsoft/dev-tunnels-ssh-tcp": "^3.11.2",
175197
"@segment/analytics-node": "^1.0.0-beta.24",
176198
"configcat-node": "^8.0.0",
177199
"js-yaml": "^4.1.0",
200+
"long": "^5.2.1",
201+
"nice-grpc": "^2.1.3",
202+
"nice-grpc-common": "^2.0.1",
178203
"node-fetch-commonjs": "^3.2.4",
204+
"node-rsa": "^1.1.1",
179205
"pkce-challenge": "^3.0.0",
206+
"prom-client": "^14.1.1",
207+
"protobufjs": "^7.2.2",
180208
"semver": "^7.3.7",
181209
"ssh-config": "^4.1.6",
182210
"ssh2": "^1.10.0",
211+
"sshpk": "^1.17.0",
183212
"tmp": "^0.2.1",
184213
"uuid": "8.1.0",
185-
"yazl": "^2.5.1",
186-
"@grpc/grpc-js": "^1.8.8",
187-
"long":"^5.2.1",
188-
"protobufjs": "^7.2.2",
189-
"prom-client": "^14.1.1"
214+
"winston": "^3.8.2",
215+
"ws": "^8.13.0",
216+
"yazl": "^2.5.1"
190217
}
191218
}

src/commands/logs.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Command } from '../commandManager';
1212
import { ILogService } from '../services/logService';
1313
import { INotificationService } from '../services/notificationService';
1414
import { ITelemetryService, UserFlowTelemetry } from '../services/telemetryService';
15+
import { Configuration } from '../configuration';
1516

1617
interface IFile {
1718
path: string;
@@ -82,7 +83,18 @@ export class ExportLogsCommand implements Command {
8283
return;
8384
}
8485

85-
return zip(saveUri.fsPath, remoteLogFiles.concat(localLogFiles));
86+
const daemonLogs: IFile[] = [];
87+
88+
const daemonPath = path.posix.join('./lssh', 'daemon.log');
89+
const daemonLogContent = await vscode.workspace.fs.readFile(vscode.Uri.file(Configuration.getDaemonLogPath()));
90+
if (daemonLogContent.byteLength > 0) {
91+
daemonLogs.push({
92+
path: daemonPath,
93+
contents: Buffer.from(daemonLogContent)
94+
});
95+
}
96+
97+
return zip(saveUri.fsPath, remoteLogFiles.concat(localLogFiles).concat(daemonLogs));
8698
});
8799
}
88100
}

src/common/async.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
export function timeout(millis: number): Promise<void> {
7-
return new Promise((resolve) => setTimeout(resolve, millis));
7+
return new Promise((resolve) => setTimeout(resolve, millis));
88
}
99

1010
export interface ITask<T> {
@@ -26,3 +26,23 @@ export async function retry<T>(task: ITask<Promise<T>>, delay: number, retries:
2626

2727
throw lastError;
2828
}
29+
30+
export async function retryWithStop<T>(task: (stop: () => void) => Promise<T>, delay: number, retries: number): Promise<T> {
31+
let lastError: Error | undefined;
32+
let stopped = false;
33+
const stop = () => {
34+
stopped = true;
35+
};
36+
for (let i = 0; i < retries; i++) {
37+
try {
38+
return await task(stop);
39+
} catch (error) {
40+
lastError = error;
41+
if (stopped) {
42+
break;
43+
}
44+
await timeout(delay);
45+
}
46+
}
47+
throw lastError;
48+
}

src/common/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export class BaseTelemetryReporter extends Disposable {
263263
cleanupPatterns.push(new RegExp(this.extension.extensionPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'));
264264
}
265265

266-
let updatedStack = stack;
266+
let updatedStack = stack?.toString() ?? '';
267267

268268
if (anonymizeFilePaths) {
269269
const cleanUpIndexes: [number, number][] = [];

src/common/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,11 @@ export function arrayEquals<T>(one: ReadonlyArray<T> | undefined, other: Readonl
120120

121121
return true;
122122
}
123+
124+
export function getServiceURL(gitpodHost: string): string {
125+
return new URL(gitpodHost).toString().replace(/\/$/, '');
126+
}
127+
128+
export function getLocalSSHUrl(gitpodHost: string): string {
129+
return 'lssh.' + (new URL(gitpodHost)).hostname;
130+
}

src/configuration.ts

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

6+
import { tmpdir } from 'os';
7+
import { join } from 'path';
68
import * as vscode from 'vscode';
79

810
// Use these functions instead of `vscode.workspace.getConfiguration` API
@@ -17,12 +19,43 @@ function getShowReleaseNotes() {
1719
return vscode.workspace.getConfiguration('gitpod').get<boolean>('showReleaseNotes', true);
1820
}
1921

20-
function getUseLocalApp() {
22+
function getUseLocalApp(useLocalSSHServer?: boolean) {
23+
if (useLocalSSHServer) {
24+
return false;
25+
}
2126
return vscode.workspace.getConfiguration('gitpod').get<boolean>('remote.useLocalApp', false);
2227
}
2328

29+
function getLocalSSHServerPort() {
30+
// TODO(local-ssh): VSCodium?
31+
// use `sudo lsof -i:<port>` to check if the port is already in use
32+
let defaultPort = 42025;
33+
if (vscode.env.appName.includes('Insiders')) {
34+
defaultPort = 42026;
35+
}
36+
return vscode.workspace.getConfiguration('gitpod').get<number>('lsshPort', defaultPort) || defaultPort;
37+
}
38+
39+
function getLocalSshIpcPort() {
40+
let defaultPort = 41025;
41+
if (vscode.env.appName.includes('Insiders')) {
42+
defaultPort = 41026;
43+
}
44+
return vscode.workspace.getConfiguration('gitpod').get<number>('lsshIpcPort', defaultPort) || defaultPort;
45+
}
46+
47+
function getDaemonLogPath(): string {
48+
if (vscode.env.appName.includes('Insiders')) {
49+
return join(tmpdir(), 'gitpod-vscode-daemon-insiders.log');
50+
}
51+
return join(tmpdir(), 'gitpod-vscode-daemon.log');
52+
}
53+
2454
export const Configuration = {
2555
getGitpodHost,
2656
getShowReleaseNotes,
27-
getUseLocalApp
57+
getUseLocalApp,
58+
getLocalSSHServerPort,
59+
getLocalSshIpcPort,
60+
getDaemonLogPath,
2861
};

src/daemonStarter.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { join } from 'path';
7+
import { spawn } from 'child_process';
8+
import { DaemonOptions, ExitCode } from './local-ssh/common';
9+
import { ILogService } from './services/logService';
10+
import { timeout } from './common/async';
11+
import { Configuration } from './configuration';
12+
import { ITelemetryService } from './services/telemetryService';
13+
14+
export async function ensureDaemonStarted(logService: ILogService, telemetryService: ITelemetryService, retry = 10) {
15+
if (retry < 0) {
16+
return;
17+
}
18+
const process = await tryStartDaemon(logService);
19+
const ok = await new Promise<boolean>(resolve => {
20+
process.once('exit', async code => {
21+
const humanReadableCode = code !== null ? ExitCode[code] : 'UNSPECIFIED';
22+
logService.error(`lssh exit with code ${humanReadableCode} ${code}`);
23+
switch (code) {
24+
case ExitCode.OK:
25+
case ExitCode.ListenPortFailed:
26+
resolve(true);
27+
return;
28+
}
29+
logService.error('lssh unexpectedly exit with code: ' + code + ' attempt retry: ' + retry);
30+
resolve(false);
31+
telemetryService.sendTelemetryException(new Error(`unexpectedly exit with code ${humanReadableCode} ${code} attempt retry: ${retry}`), { humanReadableCode, code: code?.toString() ?? 'null' });
32+
});
33+
});
34+
if (!ok) {
35+
await timeout(1000);
36+
await ensureDaemonStarted(logService, telemetryService, retry - 1);
37+
}
38+
}
39+
40+
const DefaultDaemonOptions: DaemonOptions = {
41+
logLevel: 'info',
42+
// use `sudo lsof -i:<port>` to check if the port is already in use
43+
ipcPort: Configuration.getLocalSshIpcPort(),
44+
serverPort: Configuration.getLocalSSHServerPort(),
45+
46+
logFilePath: Configuration.getDaemonLogPath(),
47+
};
48+
49+
export function parseArgv(options: DaemonOptions): string[] {
50+
return [options.logFilePath, options.logLevel, options.serverPort.toString(), options.ipcPort.toString()];
51+
}
52+
53+
export async function tryStartDaemon(logService: ILogService, options?: Partial<DaemonOptions>) {
54+
const opts: DaemonOptions = { ...DefaultDaemonOptions, ...options };
55+
const args: string[] = [join(__dirname, 'local-ssh/daemon.js'), ...parseArgv(opts)];
56+
logService.debug('going to start local-ssh daemon', opts, args);
57+
const daemon = spawn(process.execPath, args, {
58+
detached: true,
59+
stdio: 'ignore',
60+
env: process.env
61+
});
62+
daemon.unref();
63+
return daemon;
64+
}

src/experiments.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { ISessionService } from './services/sessionService';
1111
import { ILogService } from './services/logService';
1212

1313
const EXPERTIMENTAL_SETTINGS = [
14-
'gitpod.remote.useLocalApp'
14+
'gitpod.remote.useLocalApp',
15+
// 'gitpod.remote.useLocalSSHServer',
1516
];
1617

1718
export class ExperimentalSettings {
@@ -114,6 +115,17 @@ export class ExperimentalSettings {
114115
dispose(): void {
115116
this.configcatClient.dispose();
116117
}
118+
119+
/**
120+
* @see https://app.configcat.com/08da1258-64fb-4a8e-8a1e-51de773884f6/08da1258-6541-4fc7-8b61-c8b47f82f3a0/08da1258-6512-4ec0-80a3-3f6aa301f853?settingId=75503
121+
*/
122+
async getUseLocalSSHServer(gitpodHost: string): Promise<boolean> {
123+
return (await this.getRaw<boolean>('gitpod_desktop_use_local_ssh_server', { gitpodHost })) ?? false;
124+
}
125+
126+
async getUsePublicAPI(gitpodHost: string): Promise<boolean> {
127+
return (await this.getRaw<boolean>('gitpod_experimental_publicApi', { gitpodHost })) ?? false;
128+
}
117129
}
118130

119131
export function isUserOverrideSetting(key: string): boolean {

src/extension.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { SessionService } from './services/sessionService';
2121
import { CommandManager } from './commandManager';
2222
import { SignInCommand } from './commands/account';
2323
import { ExportLogsCommand } from './commands/logs';
24+
import { ensureDaemonStarted } from './daemonStarter';
25+
import { ExtensionServiceServer } from './local-ssh/ipc/extension';
2426

2527
// connect-web uses fetch api, so we need to polyfill it
2628
if (!global.fetch) {
@@ -59,7 +61,7 @@ export async function activate(context: vscode.ExtensionContext) {
5961

6062
logger.info(`${extensionId}/${packageJSON.version} (${os.release()} ${os.platform()} ${os.arch()}) vscode/${vscode.version} (${vscode.env.appName})`);
6163

62-
telemetryService = new TelemetryService(extensionId, packageJSON.version, packageJSON.segmentKey, logger!);
64+
telemetryService = new TelemetryService(extensionId, packageJSON.version, packageJSON.segmentKey, logger!, context.extensionMode === vscode.ExtensionMode.Production);
6365

6466
const notificationService = new NotificationService(telemetryService);
6567

@@ -85,6 +87,9 @@ export async function activate(context: vscode.ExtensionContext) {
8587
const remoteConnector = new RemoteConnector(context, sessionService, hostService, experiments, logger, telemetryService, notificationService);
8688
context.subscriptions.push(remoteConnector);
8789

90+
const extensionIPCService = new ExtensionServiceServer(logger, sessionService, hostService, notificationService, telemetryService, experiments);
91+
context.subscriptions.push(extensionIPCService);
92+
8893
context.subscriptions.push(vscode.window.registerUriHandler({
8994
handleUri(uri: vscode.Uri) {
9095
// logger.trace('Handling Uri...', uri.toString());
@@ -102,6 +107,8 @@ export async function activate(context: vscode.ExtensionContext) {
102107
commandManager.register(new SignInCommand(sessionService));
103108
commandManager.register(new ExportLogsCommand(context.logUri, notificationService, telemetryService, logger));
104109

110+
ensureDaemonStarted(logger, telemetryService, 3).then(() => { }).catch(e => { logger?.error(e) });
111+
105112
if (!context.globalState.get<boolean>(FIRST_INSTALL_KEY, false)) {
106113
context.globalState.update(FIRST_INSTALL_KEY, true);
107114
telemetryService.sendTelemetryEvent('gitpod_desktop_installation', { kind: 'install' });
@@ -138,7 +145,7 @@ export async function activate(context: vscode.ExtensionContext) {
138145
logger?.info('Activation properties:', JSON.stringify(rawActivateProperties, undefined, 2));
139146
telemetryService?.sendTelemetryEvent('vscode_desktop_activate', {
140147
...rawActivateProperties,
141-
remoteUri: String(!!rawActivateProperties.remoteUri)
148+
remoteUri: String(!!rawActivateProperties.remoteUri)
142149
});
143150
}
144151
}

0 commit comments

Comments
 (0)