From bcb4a42f17996c2848ad75741026c3ee647fbd35 Mon Sep 17 00:00:00 2001 From: Jacob Roberts Date: Wed, 20 Aug 2025 15:36:49 -0700 Subject: [PATCH 1/2] keepalive events in typescript sdk --- ts/package-lock.json | 18 ++-- ts/package.json | 4 +- ts/src/connections/index.ts | 2 + ts/src/connections/package.json | 4 +- ts/src/connections/sshKeepAliveEventArgs.ts | 16 +++ ts/src/connections/tunnelConnection.ts | 11 ++ ts/src/connections/tunnelConnectionBase.ts | 27 +++++ ts/src/connections/tunnelConnectionOptions.ts | 12 +++ ts/src/connections/tunnelConnectionSession.ts | 2 +- ts/src/connections/tunnelRelayTunnelClient.ts | 9 ++ ts/src/connections/tunnelRelayTunnelHost.ts | 11 +- .../tunnelConnectionKeepAliveTests.ts | 100 ++++++++++++++++++ 12 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 ts/src/connections/sshKeepAliveEventArgs.ts create mode 100644 ts/test/tunnels-test/tunnelConnectionKeepAliveTests.ts diff --git a/ts/package-lock.json b/ts/package-lock.json index 7aa3e3c8..bbd5931e 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -8,8 +8,8 @@ "name": "@microsoft/dev-tunnels", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-ssh": "^3.12.5", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.5", + "@microsoft/dev-tunnels-ssh": "^3.12.12", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.12", "await-semaphore": "^0.1.3", "axios": "^1.8.4", "buffer": "^5.2.1", @@ -154,9 +154,10 @@ "dev": true }, "node_modules/@microsoft/dev-tunnels-ssh": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.5.tgz", - "integrity": "sha512-elDcBd4yBOfjCmmISv0cC/SeTmuF4Pj1Y5+aBVXuvg2xdfl/sF7gIPgYbiFpPxU8P6zyN+JlxWbwZkRABjlxaA==", + "version": "3.12.12", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.12.tgz", + "integrity": "sha512-UPEvlAaTVrILS/1lUDnD1QROR/yOGazld5w4IbPoDWTyIy8AgiuMsdrqR2nCH9y1FPT8rWKLvEs8qvxZtK0fTQ==", + "license": "MIT", "dependencies": { "buffer": "^5.2.1", "debug": "^4.1.1", @@ -165,9 +166,10 @@ } }, "node_modules/@microsoft/dev-tunnels-ssh-tcp": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.5.tgz", - "integrity": "sha512-/3kaDJpWPq+bRJJqiI3cqJsLfk8yddJ0+d3KmjIlcwF9wQewGp+rgTTdv29gl9dTm/cAhJh655Qh+vBHfrquZw==", + "version": "3.12.12", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.12.tgz", + "integrity": "sha512-sth7fEvTyT1Bovwn+GKm+2nT4YY2vh6A52DVysFNMi9+7pWKBmTHvvhgXHQOuon2npy/rwDJU2p1pd9ilOMYjQ==", + "license": "MIT", "dependencies": { "@microsoft/dev-tunnels-ssh": "~3.12" } diff --git a/ts/package.json b/ts/package.json index e22754b8..f900f2ae 100644 --- a/ts/package.json +++ b/ts/package.json @@ -21,8 +21,8 @@ "build-pack-publish": "npm run build && npm run pack && npm run publish" }, "dependencies": { - "@microsoft/dev-tunnels-ssh": "^3.12.5", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.5", + "@microsoft/dev-tunnels-ssh": "^3.12.12", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.12", "await-semaphore": "^0.1.3", "axios": "^1.8.4", "buffer": "^5.2.1", diff --git a/ts/src/connections/index.ts b/ts/src/connections/index.ts index e581a921..4fbc4e0e 100644 --- a/ts/src/connections/index.ts +++ b/ts/src/connections/index.ts @@ -17,6 +17,8 @@ export * from './tunnelRelayTunnelClient'; export * from './tunnelRelayTunnelHost'; export * from './tunnelConnection'; export * from './tunnelConnectionBase'; +export * from './tunnelConnectionOptions'; +export * from './sshKeepAliveEventArgs'; export * from './connectionStatus'; export * from './connectionStatusChangedEventArgs'; export { maxReconnectDelayMs } from './relayTunnelConnector'; diff --git a/ts/src/connections/package.json b/ts/src/connections/package.json index 72bbb722..6fe45eb5 100644 --- a/ts/src/connections/package.json +++ b/ts/src/connections/package.json @@ -20,8 +20,8 @@ "vscode-jsonrpc": "^4.0.0", "@microsoft/dev-tunnels-contracts": "^1.3.0", "@microsoft/dev-tunnels-management": "^1.3.0", - "@microsoft/dev-tunnels-ssh": "^3.12.5", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.5", + "@microsoft/dev-tunnels-ssh": "^3.12.12", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.12", "uuid": "^3.3.3", "await-semaphore": "^0.1.3", "websocket": "^1.0.28", diff --git a/ts/src/connections/sshKeepAliveEventArgs.ts b/ts/src/connections/sshKeepAliveEventArgs.ts new file mode 100644 index 00000000..63cb8c52 --- /dev/null +++ b/ts/src/connections/sshKeepAliveEventArgs.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Event args raised when an SSH keep-alive succeeds or fails. + */ +export class SshKeepAliveEventArgs { + /** + * The number of keep-alive messages that have been sent with the same state. + */ + public readonly count: number; + + public constructor(count: number) { + this.count = count; + } +} diff --git a/ts/src/connections/tunnelConnection.ts b/ts/src/connections/tunnelConnection.ts index 546e0546..ec56fbb0 100644 --- a/ts/src/connections/tunnelConnection.ts +++ b/ts/src/connections/tunnelConnection.ts @@ -7,6 +7,7 @@ import { ConnectionStatusChangedEventArgs } from './connectionStatusChangedEvent import { RefreshingTunnelAccessTokenEventArgs } from './refreshingTunnelAccessTokenEventArgs'; import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { ForwardedPortConnectingEventArgs } from '@microsoft/dev-tunnels-ssh-tcp'; +import { SshKeepAliveEventArgs } from './sshKeepAliveEventArgs'; /** * Tunnel connection. @@ -45,6 +46,16 @@ export interface TunnelConnection { */ readonly forwardedPortConnecting: Event; + /** + * Event raised when a keep-alive message response is not received. + */ + readonly keepAliveFailed: Event; + + /** + * Event raised when a keep-alive message response is received. + */ + readonly keepAliveSucceeded: Event; + /** * Disposes this tunnel session. */ diff --git a/ts/src/connections/tunnelConnectionBase.ts b/ts/src/connections/tunnelConnectionBase.ts index 22825c49..aec41d6d 100644 --- a/ts/src/connections/tunnelConnectionBase.ts +++ b/ts/src/connections/tunnelConnectionBase.ts @@ -11,6 +11,7 @@ import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEve import { TunnelAccessTokenProperties } from '@microsoft/dev-tunnels-management'; import { ForwardedPortConnectingEventArgs } from '@microsoft/dev-tunnels-ssh-tcp'; import { TrackingEmitter } from './utils'; +import { SshKeepAliveEventArgs } from './sshKeepAliveEventArgs'; /** * Tunnel connection base class. @@ -27,6 +28,8 @@ export class TunnelConnectionBase implements TunnelConnection { new Emitter(); private readonly forwardedPortConnectingEmitter = new Emitter(); + private readonly keepAliveFailedEmitter = new Emitter(); + private readonly keepAliveSucceededEmitter = new Emitter(); protected constructor( /** @@ -117,10 +120,34 @@ export class TunnelConnectionBase implements TunnelConnection { */ public readonly forwardedPortConnecting = this.forwardedPortConnectingEmitter.event; + /** + * Event raised when a keep-alive message response is not received. + */ + public readonly keepAliveFailed = this.keepAliveFailedEmitter.event; + + /** + * Event raised when a keep-alive message response is received. + */ + public readonly keepAliveSucceeded = this.keepAliveSucceededEmitter.event; + protected onForwardedPortConnecting(e: ForwardedPortConnectingEventArgs) { this.forwardedPortConnectingEmitter.fire(e); } + /** + * Raises the keep-alive failed event. + */ + protected onKeepAliveFailed(count: number) { + this.keepAliveFailedEmitter.fire(new SshKeepAliveEventArgs(count)); + } + + /** + * Raises the keep-alive succeeded event. + */ + protected onKeepAliveSucceeded(count: number) { + this.keepAliveSucceededEmitter.fire(new SshKeepAliveEventArgs(count)); + } + /** * Closes and disposes the tunnel session. */ diff --git a/ts/src/connections/tunnelConnectionOptions.ts b/ts/src/connections/tunnelConnectionOptions.ts index c03e3554..0e696b1d 100644 --- a/ts/src/connections/tunnelConnectionOptions.ts +++ b/ts/src/connections/tunnelConnectionOptions.ts @@ -56,4 +56,16 @@ export interface TunnelConnectionOptions { * (most common). This option applies only to client connections. */ hostId?: string; + + /** + * Gets or sets the SSH keep-alive interval in seconds. Default is 0 (disabled). + * When set to a positive value, the client/host will send SSH keep-alive messages + * and raise keep-alive events with the number of consecutive successes or failures. + * + * The keep-alive events are raised at the time of sending the next keep-alive request. + * For example, if the interval is set to 10 seconds, the first request is sent after + * 10 seconds of inactivity and waits for 10 more seconds to call the keep-alive + * callback before sending another keep-alive request. + */ + keepAliveIntervalInSeconds?: number; } diff --git a/ts/src/connections/tunnelConnectionSession.ts b/ts/src/connections/tunnelConnectionSession.ts index 363dc220..2a99e22a 100644 --- a/ts/src/connections/tunnelConnectionSession.ts +++ b/ts/src/connections/tunnelConnectionSession.ts @@ -52,7 +52,7 @@ import { v4 as uuidv4 } from 'uuid'; * Tunnel connection session. */ export class TunnelConnectionSession extends TunnelConnectionBase implements TunnelSession { - private connectionOptions?: TunnelConnectionOptions; + protected connectionOptions?: TunnelConnectionOptions; private connectedTunnel: Tunnel | null = null; private connector?: TunnelConnector; private reconnectPromise?: Promise; diff --git a/ts/src/connections/tunnelRelayTunnelClient.ts b/ts/src/connections/tunnelRelayTunnelClient.ts index c3b5e757..1a482d3f 100644 --- a/ts/src/connections/tunnelRelayTunnelClient.ts +++ b/ts/src/connections/tunnelRelayTunnelClient.ts @@ -261,6 +261,12 @@ export class TunnelRelayTunnelClient extends TunnelConnectionSession implements // session is optional since it is already over a TLS websocket. config.keyExchangeAlgorithms.splice(0, 0, SshAlgorithms.keyExchange.none); } + + // Configure keep-alive if requested + const keepAliveInterval = this.connectionOptions?.keepAliveIntervalInSeconds; + if (keepAliveInterval && keepAliveInterval > 0) { + config.keepAliveTimeoutInSeconds = keepAliveInterval; + } }); this.sshSession.trace = this.trace; this.sshSession.onReportProgress( @@ -272,6 +278,9 @@ export class TunnelRelayTunnelClient extends TunnelConnectionSession implements this.sshSession.onDisconnected(this.onSshSessionDisconnected, this, this.sshSessionDisposables); this.sshSession.onRequest(this.onRequest, this, this.sshSessionDisposables); + this.sshSession.onKeepAliveFailed((count) => this.onKeepAliveFailed(count)); + this.sshSession.onKeepAliveSucceeded((count) => this.onKeepAliveSucceeded(count)); + const pfs = this.sshSession.activateService(PortForwardingService); if (this.connectionProtocol === webSocketSubProtocolv2) { pfs.messageFactory = this; diff --git a/ts/src/connections/tunnelRelayTunnelHost.ts b/ts/src/connections/tunnelRelayTunnelHost.ts index 04f1fa40..715fc073 100644 --- a/ts/src/connections/tunnelRelayTunnelHost.ts +++ b/ts/src/connections/tunnelRelayTunnelHost.ts @@ -471,6 +471,12 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu const session = SshHelpers.createSshServerSession(this.reconnectableSessions, (config) => { config.protocolExtensions.push(SshProtocolExtensionNames.sessionReconnect); config.addService(PortForwardingService); + + // Configure keep-alive if requested + const keepAliveInterval = this.connectionOptions?.keepAliveIntervalInSeconds; + if (keepAliveInterval && keepAliveInterval > 0) { + config.keepAliveTimeoutInSeconds = keepAliveInterval; + } }); session.trace = this.trace; session.onReportProgress( @@ -482,7 +488,7 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu }; const tcs = new PromiseCompletionSource(); - + const authenticatingEventRegistration = session.onAuthenticating((e) => { this.onSshClientAuthenticating(e); }); @@ -504,6 +510,9 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu tcs.resolve(); }); + session.onKeepAliveFailed((count) => this.onKeepAliveFailed(count)); + session.onKeepAliveSucceeded((count) => this.onKeepAliveSucceeded(count)); + try { const nodeStream = new NodeStream(stream); await session.connect(nodeStream); diff --git a/ts/test/tunnels-test/tunnelConnectionKeepAliveTests.ts b/ts/test/tunnels-test/tunnelConnectionKeepAliveTests.ts new file mode 100644 index 00000000..944e878d --- /dev/null +++ b/ts/test/tunnels-test/tunnelConnectionKeepAliveTests.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import { suite, test, slow, timeout } from '@testdeck/mocha'; +import { MockTunnelManagementClient } from './mocks/mockTunnelManagementClient'; +import { + Tunnel, + TunnelConnectionMode, + TunnelRelayTunnelEndpoint, +} from '@microsoft/dev-tunnels-contracts'; +import { + TunnelRelayTunnelClient, + TunnelRelayTunnelHost, + TunnelConnectionOptions, + SshKeepAliveEventArgs, +} from '@microsoft/dev-tunnels-connections'; +import { + KeyPair, + SshAlgorithms, +} from '@microsoft/dev-tunnels-ssh'; + +@suite +export class TunnelConnectionKeepAliveTests { + private hostKeys!: KeyPair; + private managementClient!: MockTunnelManagementClient; + private tunnel!: Tunnel; + + public async before() { + this.hostKeys = await SshAlgorithms.publicKey.ecdsaSha2Nistp384!.generateKeyPair(); + this.managementClient = new MockTunnelManagementClient(); + this.tunnel = { + tunnelId: 'tunnel1', + name: 'Test Tunnel', + domain: 'localhost', + accessTokens: {}, + endpoints: [ + { + id: 'endpoint1', + connectionMode: TunnelConnectionMode.TunnelRelay, + hostRelayUri: 'wss://localhost:8080/tunnel', + clientRelayUri: 'wss://localhost:8080/tunnel', + } as TunnelRelayTunnelEndpoint, + ], + }; + } + + @test + public async testClientKeepAlive() { + const client = new TunnelRelayTunnelClient(this.managementClient); + + let failedEvent: SshKeepAliveEventArgs | undefined; + let succeededEvent: SshKeepAliveEventArgs | undefined; + + const failedSubscription = client.keepAliveFailed((e: SshKeepAliveEventArgs) => { + failedEvent = e; + }); + + const succeededSubscription = client.keepAliveSucceeded((e: SshKeepAliveEventArgs) => { + succeededEvent = e; + }); + + (client as any).onKeepAliveFailed(1); + (client as any).onKeepAliveSucceeded(1); + + assert.strictEqual(failedEvent?.count, 1, 'Failed event should fire'); + assert.strictEqual(succeededEvent?.count, 1, 'Succeeded event should fire'); + + failedSubscription.dispose(); + succeededSubscription.dispose(); + await client.dispose(); + } + + @test + public async testHostKeepAlive() { + const host = new TunnelRelayTunnelHost(this.managementClient); + + let failedEvent: SshKeepAliveEventArgs | undefined; + let succeededEvent: SshKeepAliveEventArgs | undefined; + + const failedSubscription = host.keepAliveFailed((e: SshKeepAliveEventArgs) => { + failedEvent = e; + }); + + const succeededSubscription = host.keepAliveSucceeded((e: SshKeepAliveEventArgs) => { + succeededEvent = e; + }); + + (host as any).onKeepAliveFailed(2); + (host as any).onKeepAliveSucceeded(2); + + assert.strictEqual(failedEvent?.count, 2, 'Failed event should fire'); + assert.strictEqual(succeededEvent?.count, 2, 'Succeeded event should fire'); + + + failedSubscription.dispose(); + succeededSubscription.dispose(); + await host.dispose(); + } +} From 70306fe876760f0bb729aad58836941c569b9e4d Mon Sep 17 00:00:00 2001 From: Jacob Roberts Date: Wed, 20 Aug 2025 15:37:12 -0700 Subject: [PATCH 2/2] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9352099..65925810 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Dev tunnels allows developers to securely expose local web services to the Inter | Reconnection | ✅ | ✅ | ❌ | ❌ | ❌ | | SSH-level Reconnection | ✅ | ✅ | ❌ | ❌ | ❌ | | Automatic tunnel access token refresh | ✅ | ✅ | ❌ | ❌ | ❌ | -| Ssh Keep-alive | ✅ | 🗓️ | ❌ | ❌ | ❌ | +| Ssh Keep-alive | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ - Supported 🚧 - In Progress