@@ -71,7 +71,7 @@ const exitProcess = async (forceExit: boolean, signal?: NodeJS.Signals) => {
71
71
} ;
72
72
73
73
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' ;
75
75
import { importKey , importKeyBytes } from '@microsoft/dev-tunnels-ssh-keys' ;
76
76
import { ExtensionServiceDefinition , GetWorkspaceAuthInfoResponse } from '../proto/typescript/ipc/v1/ipc' ;
77
77
import { Client , ClientError , Status , createChannel , createClient } from 'nice-grpc' ;
@@ -158,15 +158,16 @@ class WebSocketSSHProxy {
158
158
// an error handler to the writable stream
159
159
const sshStream = stream . Duplex . from ( { readable : process . stdin , writable : process . stdout } ) ;
160
160
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 ) ) {
163
162
this . telemetryService . sendTelemetryException ( new WrapError ( 'Unexpected sshStream error' , e ) ) ;
164
163
}
165
164
// HACK:
166
165
// Seems there's a bug in the ssh library that could hang forever when the stream gets closed
167
166
// so the below `await pipePromise` will never return and the node process will never exit.
168
167
// So let's just force kill here
169
- setTimeout ( ( ) => exitProcess ( true ) , 50 ) ;
168
+ setTimeout ( ( ) => {
169
+ exitProcess ( true ) ;
170
+ } , 50 ) ;
170
171
} ) ;
171
172
// sshStream.on('end', () => {
172
173
// setTimeout(() => doProcessExit(0), 50);
@@ -192,12 +193,12 @@ class WebSocketSSHProxy {
192
193
pipePromise = session . pipe ( pipeSession ) ;
193
194
return { } ;
194
195
} ) . catch ( async err => {
196
+ this . logService . error ( 'failed to authenticate proxy with username: ' + e . username ?? '' , err ) ;
197
+
198
+ this . flow . failureCode = getFailureCode ( err ) ;
195
199
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 ;
201
202
}
202
203
203
204
this . sendUserStatusFlow ( 'failed' ) ;
@@ -208,7 +209,6 @@ class WebSocketSSHProxy {
208
209
// Await a few seconds to delay showing ssh extension error modal dialog
209
210
await timeout ( 5000 ) ;
210
211
211
- this . logService . error ( 'failed to authenticate proxy with username: ' + e . username ?? '' , err ) ;
212
212
await session . close ( SshDisconnectReason . byApplication , err . toString ( ) , err instanceof Error ? err : undefined ) ;
213
213
return null ;
214
214
} ) ;
@@ -220,6 +220,7 @@ class WebSocketSSHProxy {
220
220
if ( session . isClosed ) {
221
221
return ;
222
222
}
223
+ e = fixSSHErrorName ( e ) ;
223
224
this . logService . error ( e , 'failed to connect to client' ) ;
224
225
this . sendErrorReport ( this . flow , e , 'failed to connect to client' ) ;
225
226
await session . close ( SshDisconnectReason . byApplication , e . toString ( ) , e instanceof Error ? e : undefined ) ;
@@ -246,85 +247,95 @@ class WebSocketSSHProxy {
246
247
}
247
248
248
249
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 ) ;
263
269
}
264
- return session ;
265
270
}
266
271
267
272
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
295
278
}
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
+ }
300
307
}
301
- }
302
308
303
- socket . on ( 'ping' , heartbeat ) ;
309
+ socket . on ( 'ping' , heartbeat ) ;
310
+ heartbeat ( ) ;
304
311
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 ) ;
311
319
}
312
- resolve ( socketWrapper ) ;
313
- }
314
- socket . onerror = ( e ) => reject ( e ) ;
315
- } ) ;
320
+ socket . onerror = ( e ) => {
321
+ reject ( e ) ;
322
+ }
323
+ } ) ;
316
324
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 ( { } ) ) ;
320
328
321
- await session . connect ( stream ) ;
329
+ await session . connect ( stream ) ;
322
330
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 ) ;
326
338
}
327
- return session ;
328
339
}
329
340
330
341
async retryGetWorkspaceInfo ( username : string ) {
@@ -368,3 +379,34 @@ proxy.start().catch(e => {
368
379
const err = new WrapError ( 'Uncaught exception on start method' , e ) ;
369
380
telemetryService . sendTelemetryException ( err , { gitpodHost : options . host } ) ;
370
381
} ) ;
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
+ }
0 commit comments