33 * Licensed under the MIT License. See License.txt in the project root for license information.
44 *--------------------------------------------------------------------------------------------*/
55
6+ interface ClientOptions {
7+ host : string ;
8+ extIpcPort : number ;
9+ machineID : string ;
10+ }
11+
12+ function getClientOptions ( ) : ClientOptions {
13+ const args = process . argv . slice ( 2 ) ;
14+ // %h is in the form of <ws_id>.vss.<gitpod_host>'
15+ // add `https://` prefix since our gitpodHost is actually a url not host
16+ const host = 'https://' + args [ 0 ] . split ( '.' ) . splice ( 2 ) . join ( '.' ) ;
17+ return {
18+ host,
19+ extIpcPort : Number . parseInt ( args [ 1 ] , 10 ) ,
20+ machineID : args [ 2 ] ?? '' ,
21+ } ;
22+ }
23+
24+ const options = getClientOptions ( ) ;
25+ if ( ! options ) {
26+ process . exit ( 1 ) ;
27+ }
28+
29+ import { NopeLogger } from './logger' ;
30+ const logService = new NopeLogger ( ) ;
31+
32+ // DO NOT PUSH CHANGES BELOW TO PRODUCTION
33+ // import { DebugLogger } from './logger';
34+ // const logService = new DebugLogger();
35+
36+ import { TelemetryService } from './telemetryService' ;
37+ const telemetryService = new TelemetryService (
38+ process . env . SEGMENT_KEY ! ,
39+ options . machineID ,
40+ process . env . EXT_NAME ! ,
41+ process . env . EXT_VERSION ! ,
42+ options . host ,
43+ logService
44+ ) ;
45+
46+ const flow : SSHUserFlowTelemetry = {
47+ flow : 'local_ssh' ,
48+ gitpodHost : options . host ,
49+ workspaceId : '' ,
50+ processId : process . pid ,
51+ } ;
52+
53+ telemetryService . sendUserFlowStatus ( 'started' , flow ) ;
54+ const sendExited = ( exitCode : number , forceExit : boolean , exitSignal ?: NodeJS . Signals ) => {
55+ return telemetryService . sendUserFlowStatus ( 'exited' , {
56+ ...flow ,
57+ exitCode,
58+ forceExit : String ( forceExit ) ,
59+ signal : exitSignal
60+ } ) ;
61+ } ;
62+ // best effort to intercept process exit
63+ const beforeExitListener = ( exitCode : number ) => {
64+ process . removeListener ( 'beforeExit' , beforeExitListener ) ;
65+ return sendExited ( exitCode , false )
66+ } ;
67+ process . addListener ( 'beforeExit' , beforeExitListener ) ;
68+ const exitProcess = async ( forceExit : boolean , signal ?: NodeJS . Signals ) => {
69+ await sendExited ( 0 , forceExit , signal ) ;
70+ process . exit ( 0 ) ;
71+ } ;
72+
673import { SshClient } from '@microsoft/dev-tunnels-ssh-tcp' ;
774import { NodeStream , SshClientCredentials , SshClientSession , SshDisconnectReason , SshServerSession , SshSessionConfiguration , Stream , WebSocketStream } from '@microsoft/dev-tunnels-ssh' ;
875import { importKey , importKeyBytes } from '@microsoft/dev-tunnels-ssh-keys' ;
@@ -13,7 +80,6 @@ import { WrapError } from '../common/utils';
1380import { WebSocket } from 'ws' ;
1481import * as stream from 'stream' ;
1582import { ILogService } from '../services/logService' ;
16- import { TelemetryService } from './telemetryService' ;
1783import { ITelemetryService , UserFlowTelemetryProperties } from '../common/telemetry' ;
1884import { LocalSSHMetricsReporter } from '../services/localSSHMetrics' ;
1985
@@ -25,24 +91,6 @@ function getHostKey(): Buffer {
2591 return Buffer . from ( HOST_KEY , 'base64' ) ;
2692}
2793
28- interface ClientOptions {
29- host : string ;
30- extIpcPort : number ;
31- machineID : string ;
32- }
33-
34- function getClientOptions ( ) : ClientOptions {
35- const args = process . argv . slice ( 2 ) ;
36- // %h is in the form of <ws_id>.vss.<gitpod_host>'
37- // add `https://` prefix since our gitpodHost is actually a url not host
38- const host = 'https://' + args [ 0 ] . split ( '.' ) . splice ( 2 ) . join ( '.' ) ;
39- return {
40- host,
41- extIpcPort : Number . parseInt ( args [ 1 ] , 10 ) ,
42- machineID : args [ 2 ] ?? '' ,
43- } ;
44- }
45-
4694type FailedToProxyCode = 'SSH.AuthenticationFailed' | 'TUNNEL.AuthenticateSSHKeyFailed' | 'NoRunningInstance' | 'FailedToGetAuthInfo' | 'GitpodHostMismatch' | 'NoAccessTokenFound' ;
4795
4896// IgnoredFailedCodes contains the failreCode that don't need to send error report
@@ -74,28 +122,22 @@ interface SSHUserFlowTelemetry extends UserFlowTelemetryProperties {
74122
75123class WebSocketSSHProxy {
76124 private extensionIpc : Client < ExtensionServiceDefinition > ;
77- private flow : SSHUserFlowTelemetry ;
78125
79126 constructor (
80127 private readonly options : ClientOptions ,
81128 private readonly telemetryService : ITelemetryService ,
82129 private readonly metricsReporter : LocalSSHMetricsReporter ,
83- private readonly logService : ILogService
130+ private readonly logService : ILogService ,
131+ private readonly flow : SSHUserFlowTelemetry
84132 ) {
85- this . flow = {
86- flow : 'local_ssh' ,
87- gitpodHost : this . options . host ,
88- workspaceId : '' ,
89- } ;
90-
91133 this . onExit ( ) ;
92134 this . onException ( ) ;
93135 this . extensionIpc = createClient ( ExtensionServiceDefinition , createChannel ( '127.0.0.1:' + this . options . extIpcPort ) ) ;
94136 }
95137
96138 private onExit ( ) {
97- const exitHandler = ( _signal ?: NodeJS . Signals ) => {
98- process . exit ( 0 ) ;
139+ const exitHandler = ( signal ?: NodeJS . Signals ) => {
140+ exitProcess ( false , signal )
99141 } ;
100142 process . on ( 'SIGINT' , exitHandler ) ;
101143 process . on ( 'SIGTERM' , exitHandler ) ;
@@ -116,19 +158,21 @@ class WebSocketSSHProxy {
116158 // an error handler to the writable stream
117159 const sshStream = stream . Duplex . from ( { readable : process . stdin , writable : process . stdout } ) ;
118160 sshStream . on ( 'error' , e => {
119- if ( ( e as any ) . code === 'EPIPE' ) {
120- // HACK:
121- // Seems there's a bug in the ssh library that could hang forever when the stream gets closed
122- // so the below `await pipePromise` will never return and the node process will never exit.
123- // So let's just force kill here
124- setTimeout ( ( ) => process . exit ( 0 ) , 50 ) ;
161+ if ( ( e as any ) . code !== 'EPIPE' ) {
162+ // TODO filter out known error codes
163+ this . logService . error ( e , 'unexpected sshStream error' ) ;
125164 }
165+ // HACK:
166+ // Seems there's a bug in the ssh library that could hang forever when the stream gets closed
167+ // so the below `await pipePromise` will never return and the node process will never exit.
168+ // So let's just force kill here
169+ setTimeout ( ( ) => exitProcess ( true ) , 50 ) ;
126170 } ) ;
127171 // sshStream.on('end', () => {
128- // setTimeout(() => process.exit (0), 50);
172+ // setTimeout(() => doProcessExit (0), 50);
129173 // });
130174 // sshStream.on('close', () => {
131- // setTimeout(() => process.exit (0), 50);
175+ // setTimeout(() => doProcessExit (0), 50);
132176 // });
133177
134178 // This is expected to never throw as key is hardcoded
@@ -227,10 +271,46 @@ class WebSocketSSHProxy {
227271 'x-gitpod-owner-token' : workspaceInfo . ownerToken
228272 }
229273 } ) ;
274+
230275 socket . binaryType = 'arraybuffer' ;
231276
232277 const stream = await new Promise < Stream > ( ( resolve , reject ) => {
233- socket . onopen = ( ) => resolve ( new WebSocketStream ( socket as any ) ) ;
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 ) ;
295+ }
296+ function stopHearbeat ( ) {
297+ if ( pingTimeout != undefined ) {
298+ clearTimeout ( pingTimeout ) ;
299+ pingTimeout = undefined ;
300+ }
301+ }
302+
303+ socket . on ( 'ping' , heartbeat ) ;
304+
305+ heartbeat ( ) ;
306+ const socketWrapper = new WebSocketStream ( socket as any ) ;
307+ const wrappedOnClose = socket . onclose ! ;
308+ socket . onclose = ( e ) => {
309+ stopHearbeat ( ) ;
310+ wrappedOnClose ( e ) ;
311+ }
312+ resolve ( socketWrapper ) ;
313+ }
234314 socket . onerror = ( e ) => reject ( e ) ;
235315 } ) ;
236316
@@ -281,30 +361,10 @@ class WebSocketSSHProxy {
281361 }
282362}
283363
284- const options = getClientOptions ( ) ;
285- if ( ! options ) {
286- process . exit ( 1 ) ;
287- }
288-
289- import { NopeLogger } from './logger' ;
290- const logService = new NopeLogger ( ) ;
291-
292- // DO NOT PUSH CHANGES BELOW TO PRODUCTION
293- // import { DebugLogger } from './logger';
294- // const logService = new DebugLogger();
295-
296- const telemetryService = new TelemetryService (
297- process . env . SEGMENT_KEY ! ,
298- options . machineID ,
299- process . env . EXT_NAME ! ,
300- process . env . EXT_VERSION ! ,
301- options . host ,
302- logService
303- ) ;
304-
305364const metricsReporter = new LocalSSHMetricsReporter ( logService ) ;
306365
307- const proxy = new WebSocketSSHProxy ( options , telemetryService , metricsReporter , logService ) ;
308- proxy . start ( ) . catch ( ( ) => {
309- // Noop, catch everything in start method pls
366+ const proxy = new WebSocketSSHProxy ( options , telemetryService , metricsReporter , logService , flow ) ;
367+ proxy . start ( ) . catch ( e => {
368+ const err = new WrapError ( 'Uncaught exception on start method' , e ) ;
369+ telemetryService . sendTelemetryException ( err , { gitpodHost : options . host } ) ;
310370} ) ;
0 commit comments