@@ -71,7 +71,7 @@ const exitProcess = async (forceExit: boolean, signal?: NodeJS.Signals) => {
7171} ;
7272
7373import { 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' ;
7575import { importKey , importKeyBytes } from '@microsoft/dev-tunnels-ssh-keys' ;
7676import { ExtensionServiceDefinition , GetWorkspaceAuthInfoResponse } from '../proto/typescript/ipc/v1/ipc' ;
7777import { 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+ }
0 commit comments