Skip to content

Commit 26215d3

Browse files
committed
Some minor fixes
- Improve error messages - terminate socket when is stale
1 parent 1ecf1c1 commit 26215d3

File tree

3 files changed

+121
-78
lines changed

3 files changed

+121
-78
lines changed

src/common/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export class WrapError extends Error {
145145
readonly code?: string
146146
) {
147147
const isErr = cause instanceof Error;
148-
super(isErr ? `${msg}: ${cause.message}` : msg);
148+
super(isErr ? `${msg}: ${cause.message}` : `${msg}: ${cause}`);
149149
if (isErr) {
150150
this.name = cause.name;
151151
this.stack = this.stack + '\n\n' + cause.stack;

src/local-ssh/proxy.ts

Lines changed: 118 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const exitProcess = async (forceExit: boolean, signal?: NodeJS.Signals) => {
7171
};
7272

7373
import { SshClient } from '@microsoft/dev-tunnels-ssh-tcp';
74-
import { NodeStream, SshClientCredentials, SshClientSession, SshDisconnectReason, SshServerSession, SshSessionConfiguration, Stream, WebSocketStream } from '@microsoft/dev-tunnels-ssh';
74+
import { NodeStream, ObjectDisposedError, SshChannelError, SshClientCredentials, SshClientSession, SshConnectionError, SshDisconnectReason, SshReconnectError, SshServerSession, SshSessionConfiguration, Stream, WebSocketStream } from '@microsoft/dev-tunnels-ssh';
7575
import { importKey, importKeyBytes } from '@microsoft/dev-tunnels-ssh-keys';
7676
import { ExtensionServiceDefinition, GetWorkspaceAuthInfoResponse } from '../proto/typescript/ipc/v1/ipc';
7777
import { Client, ClientError, Status, createChannel, createClient } from 'nice-grpc';
@@ -158,15 +158,16 @@ class WebSocketSSHProxy {
158158
// an error handler to the writable stream
159159
const sshStream = stream.Duplex.from({ readable: process.stdin, writable: process.stdout });
160160
sshStream.on('error', e => {
161-
if ((e as any).code !== 'EPIPE') {
162-
// TODO filter out known error codes
161+
if (!['EPIPE', 'ERR_STREAM_PREMATURE_CLOSE'].includes((e as any).code)) {
163162
this.telemetryService.sendTelemetryException(new WrapError('Unexpected sshStream error', e));
164163
}
165164
// HACK:
166165
// Seems there's a bug in the ssh library that could hang forever when the stream gets closed
167166
// so the below `await pipePromise` will never return and the node process will never exit.
168167
// So let's just force kill here
169-
setTimeout(() => exitProcess(true), 50);
168+
setTimeout(() => {
169+
exitProcess(true);
170+
}, 50);
170171
});
171172
// sshStream.on('end', () => {
172173
// setTimeout(() => doProcessExit(0), 50);
@@ -192,12 +193,12 @@ class WebSocketSSHProxy {
192193
pipePromise = session.pipe(pipeSession);
193194
return {};
194195
}).catch(async err => {
196+
this.logService.error('failed to authenticate proxy with username: ' + e.username ?? '', err);
197+
198+
this.flow.failureCode = getFailureCode(err);
195199
let sendErrorReport = true;
196-
if (err instanceof FailedToProxyError) {
197-
this.flow.failureCode = err.failureCode;
198-
if (IgnoredFailedCodes.includes(err.failureCode)) {
199-
sendErrorReport = false;
200-
}
200+
if (err instanceof FailedToProxyError && IgnoredFailedCodes.includes(err.failureCode)) {
201+
sendErrorReport = false;
201202
}
202203

203204
this.sendUserStatusFlow('failed');
@@ -208,7 +209,6 @@ class WebSocketSSHProxy {
208209
// Await a few seconds to delay showing ssh extension error modal dialog
209210
await timeout(5000);
210211

211-
this.logService.error('failed to authenticate proxy with username: ' + e.username ?? '', err);
212212
await session.close(SshDisconnectReason.byApplication, err.toString(), err instanceof Error ? err : undefined);
213213
return null;
214214
});
@@ -220,6 +220,7 @@ class WebSocketSSHProxy {
220220
if (session.isClosed) {
221221
return;
222222
}
223+
e = fixSSHErrorName(e);
223224
this.logService.error(e, 'failed to connect to client');
224225
this.sendErrorReport(this.flow, e, 'failed to connect to client');
225226
await session.close(SshDisconnectReason.byApplication, e.toString(), e instanceof Error ? e : undefined);
@@ -246,85 +247,95 @@ class WebSocketSSHProxy {
246247
}
247248

248249
private async tryDirectSSH(workspaceInfo: GetWorkspaceAuthInfoResponse): Promise<SshClientSession> {
249-
const connConfig = {
250-
host: `${workspaceInfo.workspaceId}.ssh.${workspaceInfo.workspaceHost}`,
251-
port: 22,
252-
username: workspaceInfo.workspaceId,
253-
password: workspaceInfo.ownerToken,
254-
};
255-
const config = new SshSessionConfiguration();
256-
const client = new SshClient(config);
257-
const session = await client.openSession(connConfig.host, connConfig.port);
258-
session.onAuthenticating((e) => e.authenticationPromise = Promise.resolve({}));
259-
const credentials: SshClientCredentials = { username: connConfig.username, password: connConfig.password };
260-
const authenticated = await session.authenticate(credentials);
261-
if (!authenticated) {
262-
throw new FailedToProxyError('SSH.AuthenticationFailed');
250+
try {
251+
const connConfig = {
252+
host: `${workspaceInfo.workspaceId}.ssh.${workspaceInfo.workspaceHost}`,
253+
port: 22,
254+
username: workspaceInfo.workspaceId,
255+
password: workspaceInfo.ownerToken,
256+
};
257+
const config = new SshSessionConfiguration();
258+
const client = new SshClient(config);
259+
const session = await client.openSession(connConfig.host, connConfig.port);
260+
session.onAuthenticating((e) => e.authenticationPromise = Promise.resolve({}));
261+
const credentials: SshClientCredentials = { username: connConfig.username, password: connConfig.password };
262+
const authenticated = await session.authenticate(credentials);
263+
if (!authenticated) {
264+
throw new FailedToProxyError('SSH.AuthenticationFailed');
265+
}
266+
return session;
267+
} catch (e) {
268+
throw fixSSHErrorName(e);
263269
}
264-
return session;
265270
}
266271

267272
private async getTunnelSSHConfig(workspaceInfo: GetWorkspaceAuthInfoResponse): Promise<SshClientSession> {
268-
const workspaceWSUrl = `wss://${workspaceInfo.workspaceId}.${workspaceInfo.workspaceHost}`;
269-
const socket = new WebSocket(workspaceWSUrl + '/_supervisor/tunnel/ssh', undefined, {
270-
headers: {
271-
'x-gitpod-owner-token': workspaceInfo.ownerToken
272-
}
273-
});
274-
275-
socket.binaryType = 'arraybuffer';
276-
277-
const stream = await new Promise<Stream>((resolve, reject) => {
278-
socket.onopen = () => {
279-
// see https://github.com/gitpod-io/gitpod/blob/a5b4a66e0f384733145855f82f77332062e9d163/components/gitpod-protocol/go/websocket.go#L31-L40
280-
const pongPeriod = 15 * 1000;
281-
const pingPeriod = pongPeriod * 9 / 10;
282-
283-
let pingTimeout: NodeJS.Timeout | undefined;
284-
const heartbeat = () => {
285-
stopHearbeat();
286-
287-
// Use `WebSocket#terminate()`, which immediately destroys the connection,
288-
// instead of `WebSocket#close()`, which waits for the close timer.
289-
// Delay should be equal to the interval at which your server
290-
// sends out pings plus a conservative assumption of the latency.
291-
pingTimeout = setTimeout(() => {
292-
// TODO(ak) if we see stale socket.terminate();
293-
this.telemetryService.sendUserFlowStatus('stale', this.flow);
294-
}, pingPeriod + 1000);
273+
try {
274+
const workspaceWSUrl = `wss://${workspaceInfo.workspaceId}.${workspaceInfo.workspaceHost}`;
275+
const socket = new WebSocket(workspaceWSUrl + '/_supervisor/tunnel/ssh', undefined, {
276+
headers: {
277+
'x-gitpod-owner-token': workspaceInfo.ownerToken
295278
}
296-
function stopHearbeat() {
297-
if (pingTimeout != undefined) {
298-
clearTimeout(pingTimeout);
299-
pingTimeout = undefined;
279+
});
280+
281+
socket.binaryType = 'arraybuffer';
282+
283+
const stream = await new Promise<Stream>((resolve, reject) => {
284+
socket.onopen = () => {
285+
// see https://github.com/gitpod-io/gitpod/blob/a5b4a66e0f384733145855f82f77332062e9d163/components/gitpod-protocol/go/websocket.go#L31-L40
286+
const pongPeriod = 15 * 1000;
287+
const pingPeriod = pongPeriod * 9 / 10;
288+
289+
let pingTimeout: NodeJS.Timeout | undefined;
290+
const heartbeat = () => {
291+
stopHearbeat();
292+
293+
// Use `WebSocket#terminate()`, which immediately destroys the connection,
294+
// instead of `WebSocket#close()`, which waits for the close timer.
295+
// Delay should be equal to the interval at which your server
296+
// sends out pings plus a conservative assumption of the latency.
297+
pingTimeout = setTimeout(() => {
298+
this.telemetryService.sendUserFlowStatus('stale', this.flow);
299+
socket.terminate();
300+
}, pingPeriod + 1000);
301+
}
302+
const stopHearbeat = () => {
303+
if (pingTimeout != undefined) {
304+
clearTimeout(pingTimeout);
305+
pingTimeout = undefined;
306+
}
300307
}
301-
}
302308

303-
socket.on('ping', heartbeat);
309+
socket.on('ping', heartbeat);
310+
heartbeat();
304311

305-
heartbeat();
306-
const socketWrapper = new WebSocketStream(socket as any);
307-
const wrappedOnClose = socket.onclose!;
308-
socket.onclose = (e) => {
309-
stopHearbeat();
310-
wrappedOnClose(e);
312+
const websocketStream = new WebSocketStream(socket as any);
313+
const wrappedOnClose = socket.onclose!;
314+
socket.onclose = (e) => {
315+
stopHearbeat();
316+
wrappedOnClose(e);
317+
}
318+
resolve(websocketStream);
311319
}
312-
resolve(socketWrapper);
313-
}
314-
socket.onerror = (e) => reject(e);
315-
});
320+
socket.onerror = (e) => {
321+
reject(e);
322+
}
323+
});
316324

317-
const config = new SshSessionConfiguration();
318-
const session = new SshClientSession(config);
319-
session.onAuthenticating((e) => e.authenticationPromise = Promise.resolve({}));
325+
const config = new SshSessionConfiguration();
326+
const session = new SshClientSession(config);
327+
session.onAuthenticating((e) => e.authenticationPromise = Promise.resolve({}));
320328

321-
await session.connect(stream);
329+
await session.connect(stream);
322330

323-
const ok = await session.authenticate({ username: 'gitpod', publicKeys: [await importKey(workspaceInfo.sshkey)] });
324-
if (!ok) {
325-
throw new FailedToProxyError('TUNNEL.AuthenticateSSHKeyFailed');
331+
const ok = await session.authenticate({ username: 'gitpod', publicKeys: [await importKey(workspaceInfo.sshkey)] });
332+
if (!ok) {
333+
throw new FailedToProxyError('TUNNEL.AuthenticateSSHKeyFailed');
334+
}
335+
return session;
336+
} catch (e) {
337+
throw fixSSHErrorName(e);
326338
}
327-
return session;
328339
}
329340

330341
async retryGetWorkspaceInfo(username: string) {
@@ -368,3 +379,34 @@ proxy.start().catch(e => {
368379
const err = new WrapError('Uncaught exception on start method', e);
369380
telemetryService.sendTelemetryException(err, { gitpodHost: options.host });
370381
});
382+
383+
function fixSSHErrorName(err: any) {
384+
if (err instanceof SshConnectionError) {
385+
err.name = 'SshConnectionError';
386+
err.message = `[${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}] ${err.message}`;
387+
} else if (err instanceof SshReconnectError) {
388+
err.name = 'SshReconnectError';
389+
err.message = `[${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}] ${err.message}`;
390+
} else if (err instanceof SshChannelError) {
391+
err.name = 'SshChannelError';
392+
err.message = `[${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}] ${err.message}`;
393+
} else if (err instanceof ObjectDisposedError) {
394+
err.name = 'ObjectDisposedError';
395+
}
396+
return err;
397+
}
398+
399+
function getFailureCode(err: any) {
400+
if (err instanceof SshConnectionError) {
401+
return `SshConnectionError.${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}`;
402+
} else if (err instanceof SshReconnectError) {
403+
return `SshReconnectError.${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}`;
404+
} else if (err instanceof SshChannelError) {
405+
return `SshChannelError.${SshDisconnectReason[err.reason ?? SshDisconnectReason.none]}`;
406+
} else if (err instanceof ObjectDisposedError) {
407+
return 'ObjectDisposedError';
408+
} else if (err instanceof FailedToProxyError) {
409+
return err.failureCode;
410+
}
411+
return undefined;
412+
}

src/local-ssh/telemetryService.ts

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

6+
import * as path from 'path';
67
import { Analytics } from '@segment/analytics-node';
78
import { ILogService } from '../services/logService';
89
import { ITelemetryService, TelemetryEventProperties, UserFlowTelemetryProperties, createSegmentAnalyticsClient, getBaseProperties, commonSendErrorData, commonSendEventData, getCleanupPatterns, TRUSTED_VALUES, cleanData } from '../common/telemetry';
@@ -26,7 +27,7 @@ export class TelemetryService implements ITelemetryService {
2627
private readonly logService: ILogService,
2728
) {
2829
this.segmentClient = createSegmentAnalyticsClient({ writeKey: this.segmentKey, maxEventsInBatch: 1 }, gitpodHost, this.logService);
29-
this.cleanupPatterns = getCleanupPatterns([]);
30+
this.cleanupPatterns = getCleanupPatterns([path.dirname(__dirname)/* globalStorage folder */]);
3031
const commonProperties = getCommonProperties(machineId, extensionId, extensionVersion);
3132
this.commonProperties = commonProperties;
3233
}

0 commit comments

Comments
 (0)