UNPKG

@microsoft/dev-tunnels-connections

Version:

Tunnels library for Visual Studio tools

655 lines 28.9 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. Object.defineProperty(exports, "__esModule", { value: true }); exports.TunnelConnectionSession = void 0; const dev_tunnels_contracts_1 = require("@microsoft/dev-tunnels-contracts"); const dev_tunnels_management_1 = require("@microsoft/dev-tunnels-management"); const dev_tunnels_ssh_1 = require("@microsoft/dev-tunnels-ssh"); const vscode_jsonrpc_1 = require("vscode-jsonrpc"); const connectionStatus_1 = require("./connectionStatus"); const relayTunnelConnector_1 = require("./relayTunnelConnector"); const utils_1 = require("./utils"); const tunnelConnectionBase_1 = require("./tunnelConnectionBase"); const dev_tunnels_ssh_tcp_1 = require("@microsoft/dev-tunnels-ssh-tcp"); const portRelayRequestMessage_1 = require("./messages/portRelayRequestMessage"); const portRelayConnectRequestMessage_1 = require("./messages/portRelayConnectRequestMessage"); const refreshingTunnelEventArgs_1 = require("./refreshingTunnelEventArgs"); const defaultTunnelRelayStreamFactory_1 = require("./defaultTunnelRelayStreamFactory"); const uuid_1 = require("uuid"); /** * Tunnel connection session. */ class TunnelConnectionSession extends tunnelConnectionBase_1.TunnelConnectionBase { /** * Name of the protocol used to connect to the tunnel. */ get connectionProtocol() { return this.connectionProtocolValue; } set connectionProtocol(value) { this.connectionProtocolValue = value; } /** * Gets an ID that is unique to this instance of `TunnelConnectionSession`, * useful for correlating connection events over time. */ get connectionId() { return this.uniqueConnectionId; } /** * A value indicating if this is a client tunnel connection (as opposed to host connection). */ get isClientConnection() { return this.tunnelAccessScope === dev_tunnels_contracts_1.TunnelAccessScopes.Connect; } /** * tunnel connection role, either "client", or "host", depending on @link tunnelAccessScope. */ get connectionRole() { return this.isClientConnection ? 'client' : 'host'; } /** * @internal onRetrying override to report tunnel events. */ onRetrying(event) { var _a; // Report tunnel event for retry if (this.tunnel && this.managementClient) { const retryingEvent = { name: `${this.connectionRole}_connect_retrying`, severity: dev_tunnels_contracts_1.TunnelEvent.warning, details: (_a = event.error) === null || _a === void 0 ? void 0 : _a.toString(), properties: { 'Retry': event.retry.toString(), 'Delay': event.delayMs.toString() }, }; this.managementClient.reportEvent(this.tunnel, retryingEvent); } super.onRetrying(event); } /** * @internal onConnectionStatusChanged override to report tunnel events. */ onConnectionStatusChanged(previousStatus, status) { // Report tunnel event for connection status change if (this.tunnel && this.managementClient) { const statusEvent = { name: `${this.connectionRole}_connection_status`, severity: dev_tunnels_contracts_1.TunnelEvent.info, details: undefined, properties: { ConnectionStatus: status.toString(), PreviousConnectionStatus: previousStatus.toString() }, }; if (previousStatus !== connectionStatus_1.ConnectionStatus.None) { // Format the duration as a TimeSpan in the form "00:00:00.000" const duration = Date.now() - this.connectionStartTime; const formattedDuration = new Date(duration).toISOString().substring(11, 23); statusEvent.properties[`${previousStatus}Duration`] = formattedDuration; } if (this.isClientConnection) { // For client sessions, report the SSH session ID property, which is derived from // the SSH key-exchange such that both host and client have the same ID. statusEvent.properties.ClientSessionId = this.getShortSessionId(this.sshSession); } else { // For host sessions, there is no SSH encryption or key-exchange. // Just use a locally-generated GUID that is unique to this session. statusEvent.properties.HostSessionId = this.connectionId; } this.managementClient.reportEvent(this.tunnel, statusEvent); } this.connectionStartTime = Date.now(); super.onConnectionStatusChanged(previousStatus, status); } constructor(tunnelAccessScope, connectionProtocols, /** * Gets the management client used for the connection. */ managementClient, trace) { super(tunnelAccessScope); this.connectionProtocols = connectionProtocols; this.managementClient = managementClient; this.connectedTunnel = null; this.connectionStartTime = Date.now(); this.uniqueConnectionId = (0, uuid_1.v4)(); this.refreshingTunnelEmitter = new utils_1.TrackingEmitter(); this.reportProgressEmitter = new vscode_jsonrpc_1.Emitter(); /** * Event that is raised to report connection progress. * * See `Progress` for a description of the different progress events that can be reported. */ this.onReportProgress = this.reportProgressEmitter.event; /** * Gets or sets a factory for creating relay streams. */ this.streamFactory = new defaultTunnelRelayStreamFactory_1.DefaultTunnelRelayStreamFactory(); this.sshSessionDisposables = []; /** * An event which fires when tunnel connection refreshes tunnel. */ this.refreshingTunnel = this.refreshingTunnelEmitter.event; /** * Determines whether E2E encryption is requested when opening connections through the tunnel * (V2 protocol only). * * The default value is true, but applications may set this to false (for slightly faster * connections). * * Note when this is true, E2E encryption is not strictly required. The tunnel relay and * tunnel host can decide whether or not to enable E2E encryption for each connection, * depending on policies and capabilities. Applications can verify the status of E2EE by * handling the `forwardedPortConnecting` event and checking the related property on the * channel request or response message. */ this.enableE2EEncryption = true; this.trace = trace !== null && trace !== void 0 ? trace : (() => { }); this.httpAgent = managementClient === null || managementClient === void 0 ? void 0 : managementClient.httpsAgent; } /* @internal */ raiseReportProgress(progress, sessionNumber) { const args = { progress, sessionNumber, }; this.reportProgressEmitter.fire(args); } /** * Get the tunnel of this tunnel connection. */ get tunnel() { return this.connectedTunnel; } set tunnel(value) { if (value !== this.connectedTunnel) { this.connectedTunnel = value; this.tunnelChanged(); } } /** * Tunnel has been assigned to or changed. */ tunnelChanged() { if (this.tunnel) { this.accessToken = dev_tunnels_management_1.TunnelAccessTokenProperties.getTunnelAccessToken(this.tunnel, this.tunnelAccessScope); } else { this.accessToken = undefined; } } /** * Gets a value indicating that this connection has already created its connector * and so can be reconnected if needed. */ get isReconnectable() { return !!this.connector; } /** * Gets the disconnection reason. * {@link SshDisconnectReason.none } if not yet disconnected. * {@link SshDisconnectReason.connectionLost} if network connection was lost and reconnects are not enabled or unsuccesfull. * {@link SshDisconnectReason.byApplication} if connection was disposed. * {@link SshDisconnectReason.tooManyConnections} if host connection was disconnected because another host connected for the same tunnel. */ get disconnectReason() { return this.disconnectionReason; } /** * Sets the disconnect reason that caused disconnection. */ set disconnectReason(reason) { this.disconnectionReason = reason; } /** * @internal Creates a stream to the tunnel. */ async createSessionStream(options, cancellation) { if (!this.relayUri) { throw new Error('Cannot create tunnel session stream. Tunnel relay endpoint URI is missing'); } if (this.isClientConnection) { this.raiseReportProgress(dev_tunnels_ssh_1.Progress.OpeningClientConnectionToRelay); } else { this.raiseReportProgress(dev_tunnels_ssh_1.Progress.OpeningHostConnectionToRelay); } this.trace(dev_tunnels_ssh_1.TraceLevel.Info, 0, `Connecting to ${this.connectionRole} tunnel relay ${this.relayUri}`); this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Sec-WebSocket-Protocol: ${this.connectionProtocols.join(', ')}`); if (this.accessToken) { const tokenTrace = dev_tunnels_management_1.TunnelAccessTokenProperties.getTokenTrace(this.accessToken); this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Authorization: tunnel <${tokenTrace}>`); } const clientConfig = { tlsOptions: { agent: this.httpAgent, }, }; const streamAndProtocol = await this.streamFactory.createRelayStream(this.relayUri, this.connectionProtocols, this.accessToken, clientConfig); this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Connected with subprotocol '${streamAndProtocol.protocol}'`); if (this.isClientConnection) { this.raiseReportProgress(dev_tunnels_ssh_1.Progress.OpenedClientConnectionToRelay); } else { this.raiseReportProgress(dev_tunnels_ssh_1.Progress.OpenedHostConnectionToRelay); } return streamAndProtocol; } /** * @internal Configures the tunnel session with the given stream. */ configureSession(stream, protocol, isReconnect, cancellation) { throw new Error('Not implemented'); } /** * @internal Closes the tunnel session due to an error. */ async closeSession(reason, error) { this.unsubscribeSessionEvents(); const session = this.sshSession; if (!session) { return; } if (!session.isClosed) { await session.close(reason || dev_tunnels_ssh_1.SshDisconnectReason.none, undefined, error); } else { this.sshSession = undefined; } // Closing the SSH session does nothing if the session is in disconnected state, // which may happen for a reconnectable session when the connection drops. // Disposing of the session forces closing and frees up the resources. session.dispose(); } /** * Disposes this tunnel session, closing the SSH session used for it. */ async dispose() { if (this.disconnectReason === dev_tunnels_ssh_1.SshDisconnectReason.none || this.disconnectReason === undefined) { this.disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.byApplication; } await super.dispose(); try { await this.closeSession(this.disconnectReason, this.disconnectError); } catch (e) { if (!(e instanceof dev_tunnels_ssh_1.ObjectDisposedError)) throw e; } } /** * Refreshes the tunnel access token. This may be useful when the Relay service responds with 401 Unauthorized. * Does nothing if the object is disposed, or there is no way to refresh the token. * @internal */ async refreshTunnelAccessToken(cancellation) { var _a; if (this.isDisposed) { return false; } if (!this.isRefreshingTunnelAccessTokenEventHandled && !this.canRefreshTunnel) { return false; } this.connectionStatus = connectionStatus_1.ConnectionStatus.RefreshingTunnelAccessToken; try { this.traceVerbose(`Refreshing tunnel access token. Current token: ${dev_tunnels_management_1.TunnelAccessTokenProperties.getTokenTrace(this.accessToken)}`); if (this.isRefreshingTunnelAccessTokenEventHandled) { this.accessToken = (_a = await this.getFreshTunnelAccessToken(cancellation)) !== null && _a !== void 0 ? _a : undefined; } else { await this.refreshTunnel(false, cancellation); } this.traceVerbose(`Refreshed tunnel access token. New token: ${dev_tunnels_management_1.TunnelAccessTokenProperties.getTokenTrace(this.accessToken)}`); return true; } finally { this.connectionStatus = connectionStatus_1.ConnectionStatus.Connecting; } } /** * @internal Start connecting relay client. */ startConnecting() { this.connectionStatus = connectionStatus_1.ConnectionStatus.Connecting; } /** * @internal Finish connecting relay client. */ finishConnecting(reason, disconnectError) { if (reason === undefined || reason === dev_tunnels_ssh_1.SshDisconnectReason.none) { if (this.connectionStatus === connectionStatus_1.ConnectionStatus.Connecting) { // If there were temporary connection issue, disconnectError may contain the old error. // Since we have successfully connected after all, clean it up. this.disconnectError = undefined; this.disconnectReason = undefined; } this.connectionStatus = connectionStatus_1.ConnectionStatus.Connected; } else if (this.connectionStatus !== connectionStatus_1.ConnectionStatus.Disconnected) { // Do not overwrite disconnect error and reason if already disconnected. this.disconnectReason = reason; if (disconnectError) { this.disconnectError = disconnectError; } this.connectionStatus = connectionStatus_1.ConnectionStatus.Disconnected; } } /** * Get a value indicating whether this session can attempt refreshing tunnel. * Note: tunnel refresh may still fail if the tunnel doesn't exist in the service, * tunnel access has changed, or tunnel access token has expired. */ get canRefreshTunnel() { return (this.tunnel && this.managementClient) || this.refreshingTunnelEmitter.isSubscribed; } /** * Fetch the tunnel from the service if {@link managementClient} and {@link tunnel} are set. */ async refreshTunnel(includePorts, cancellation) { this.traceInfo('Refreshing tunnel.'); let isRefreshed = false; const e = new refreshingTunnelEventArgs_1.RefreshingTunnelEventArgs(this.tunnelAccessScope, this.tunnel, !!includePorts, this.managementClient, cancellation); this.refreshingTunnelEmitter.fire(e); if (e.tunnelPromise) { this.tunnel = await e.tunnelPromise; isRefreshed = true; } if (!isRefreshed && this.tunnel && this.managementClient) { const options = { tokenScopes: [this.tunnelAccessScope], includePorts, }; this.tunnel = await (0, utils_1.withCancellation)(this.managementClient.getTunnel(this.tunnel, options), cancellation); isRefreshed = true; } if (isRefreshed) { if (this.tunnel) { this.traceInfo('Refreshed tunnel.'); } else { this.traceInfo('Tunnel not found.'); } } return true; } /** * Creates a tunnel connector */ createTunnelConnector() { return new relayTunnelConnector_1.RelayTunnelConnector(this); } /** * Trace info message. */ traceInfo(msg) { this.trace(dev_tunnels_ssh_1.TraceLevel.Info, 0, msg); } /** * Trace verbose message. */ traceVerbose(msg) { this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, msg); } /** * Trace warning message. */ traceWarning(msg, err) { this.trace(dev_tunnels_ssh_1.TraceLevel.Warning, 0, msg, err); } /** * Trace error message. */ traceError(msg, err) { this.trace(dev_tunnels_ssh_1.TraceLevel.Error, 0, msg, err); } /** * SSH session closed event handler. Child classes may use it unsubscribe session events and maybe start reconnecting. */ onSshSessionClosed(e) { this.unsubscribeSessionEvents(); this.sshSession = undefined; this.maybeStartReconnecting(e.reason, e.message, e.error); } /** * Start reconnecting if the tunnel connection is not yet disposed. */ maybeStartReconnecting(reason, message, error) { var _a, _b, _c, _d; const traceMessage = `Connection to ${this.connectionRole} tunnel relay closed.${this.getDisconnectReason(reason, message, error)}`; if (this.isDisposed || this.connectionStatus === connectionStatus_1.ConnectionStatus.Disconnected) { // Disposed or disconnected already. // This reconnection attempt may be caused by closing SSH session on dispose. this.traceInfo(traceMessage); return; } if (error) { this.disconnectError = error; this.disconnectReason = reason; } if (this.connectionStatus !== connectionStatus_1.ConnectionStatus.Connected || this.reconnectPromise) { // Not connected or already connecting. this.traceInfo(traceMessage); return; } // Reconnect if connection is lost, reconnect is enabled, and connector exists. // The connector may be undefined if the tunnel client/host was created directly from a stream. if (((_b = (_a = this.connectionOptions) === null || _a === void 0 ? void 0 : _a.enableReconnect) !== null && _b !== void 0 ? _b : true) && reason === dev_tunnels_ssh_1.SshDisconnectReason.connectionLost && this.connector) { // Report reconnect event if (this.tunnel && this.managementClient) { const reconnectEvent = { name: `${this.connectionRole}_reconnect`, severity: dev_tunnels_contracts_1.TunnelEvent.warning, details: (_c = error === null || error === void 0 ? void 0 : error.toString()) !== null && _c !== void 0 ? _c : message, properties: { ClientSessionId: this.getShortSessionId(this.sshSession), }, }; this.managementClient.reportEvent(this.tunnel, reconnectEvent); } this.traceInfo(`${traceMessage} Reconnecting.`); this.reconnectPromise = (async () => { try { await this.connectTunnelSession(); } catch (ex) { // Report reconnect failed event if (this.tunnel && this.managementClient) { const reconnectFailedEvent = { name: `${this.connectionRole}_reconnect_failed`, severity: dev_tunnels_contracts_1.TunnelEvent.error, details: ex instanceof Error ? ex.toString() : String(ex), properties: { ClientSessionId: this.getShortSessionId(this.sshSession), }, }; this.managementClient.reportEvent(this.tunnel, reconnectFailedEvent); } // Tracing of the error has already been done by connectTunnelSession. // As reconnection is an async process, there is nobody watching it throw. // The error, if it was not cancellation, is stored in disconnectError property. // There might have been connectionStatusChanged event fired as well. } this.reconnectPromise = undefined; })(); } else { // Report disconnect event if (this.tunnel && this.managementClient) { const disconnectEvent = { name: `${this.connectionRole}_disconnect`, severity: dev_tunnels_contracts_1.TunnelEvent.warning, details: (_d = error === null || error === void 0 ? void 0 : error.toString()) !== null && _d !== void 0 ? _d : message, properties: { ClientSessionId: this.getShortSessionId(this.sshSession), }, }; this.managementClient.reportEvent(this.tunnel, disconnectEvent); } this.traceInfo(traceMessage); this.connectionStatus = connectionStatus_1.ConnectionStatus.Disconnected; } } /** * Get a user-readable reason for SSH session disconnection, or an empty string. */ getDisconnectReason(reason, message, error) { switch (reason) { case dev_tunnels_ssh_1.SshDisconnectReason.connectionLost: return ` ${message || (error === null || error === void 0 ? void 0 : error.message) || 'Connection lost.'}`; case dev_tunnels_ssh_1.SshDisconnectReason.authCancelledByUser: case dev_tunnels_ssh_1.SshDisconnectReason.noMoreAuthMethodsAvailable: case dev_tunnels_ssh_1.SshDisconnectReason.hostNotAllowedToConnect: case dev_tunnels_ssh_1.SshDisconnectReason.illegalUserName: return ' Not authorized.'; case dev_tunnels_ssh_1.SshDisconnectReason.serviceNotAvailable: return ' Service not available.'; case dev_tunnels_ssh_1.SshDisconnectReason.compressionError: case dev_tunnels_ssh_1.SshDisconnectReason.keyExchangeFailed: case dev_tunnels_ssh_1.SshDisconnectReason.macError: case dev_tunnels_ssh_1.SshDisconnectReason.protocolError: return ' Protocol error.'; case dev_tunnels_ssh_1.SshDisconnectReason.tooManyConnections: return this.isClientConnection ? ' Too many client connections.' : ' Another host for the tunnel has connected.'; default: return ''; } } /** * Connect to the tunnel session by running the provided {@link action}. */ async connectSession(action) { try { await action(); } catch (e) { if (!(e instanceof dev_tunnels_ssh_1.CancellationError)) { if (e instanceof Error) { this.traceError(`Error connecting ${this.connectionRole} tunnel session: ${e.message}`, e); } else { const message = `Error connecting ${this.connectionRole} tunnel session: ${e}`; this.traceError(message); } if (this.tunnel && this.managementClient) { const connectFailedEvent = { name: `${this.connectionRole}_connect_failed`, severity: dev_tunnels_contracts_1.TunnelEvent.error, details: e instanceof Error ? e.toString() : String(e), }; this.managementClient.reportEvent(this.tunnel, connectFailedEvent); } } throw e; } } /** * Connect to the tunnel session with the tunnel connector. * @param tunnel Tunnel to use for the connection. * Undefined if the connection information is already known and the tunnel is not needed. * Tunnel object to get the connection information from that tunnel. */ async connectTunnelSession(tunnel, options, cancellation) { var _a; if (tunnel) { this.tunnel = tunnel; } if (options) { this.connectionOptions = options; (_a = this.httpAgent) !== null && _a !== void 0 ? _a : (this.httpAgent = options === null || options === void 0 ? void 0 : options.httpAgent); } await this.connectSession(async () => { const isReconnect = this.isReconnectable && !tunnel; await this.onConnectingToTunnel(); if (!this.connector) { this.connector = this.createTunnelConnector(); } const disposables = []; if (cancellation) { // Link the provided cancellation token with the dispose token. const linkedCancellationSource = new vscode_jsonrpc_1.CancellationTokenSource(); disposables.push(linkedCancellationSource, cancellation.onCancellationRequested(() => linkedCancellationSource.cancel()), this.disposeToken.onCancellationRequested(() => linkedCancellationSource.cancel())); cancellation = linkedCancellationSource.token; } else { cancellation = this.disposeToken; } try { await this.connector.connectSession(isReconnect, options, cancellation); } catch (e) { if (e instanceof dev_tunnels_ssh_1.CancellationError) { this.throwIfDisposed(`CancelationError: ${e.message}`, e.stack); } throw e; } finally { for (const disposable of disposables) disposable.dispose(); } }); } /** * Validate the {@link tunnel} and get data needed to connect to it, if the tunnel is provided; * otherwise, ensure that there is already sufficient data to connect to a tunnel. */ onConnectingToTunnel() { return Promise.resolve(); } /** * Validates tunnel access token if it's present. Returns the token. * Note: uses client's system time for the validation. */ validateAccessToken() { if (this.accessToken) { dev_tunnels_management_1.TunnelAccessTokenProperties.validateTokenExpiration(this.accessToken); return this.accessToken; } } /** @internal */ createRequestMessageAsync(port) { const message = new portRelayRequestMessage_1.PortRelayRequestMessage(); message.accessToken = this.accessToken; return Promise.resolve(message); } /** @internal */ createSuccessMessageAsync(port) { const message = new dev_tunnels_ssh_tcp_1.PortForwardSuccessMessage(); return Promise.resolve(message); } /** @internal */ createChannelOpenMessageAsync(port) { const message = new portRelayConnectRequestMessage_1.PortRelayConnectRequestMessage(); message.accessToken = this.accessToken; message.isE2EEncryptionRequested = this.enableE2EEncryption; return Promise.resolve(message); } /** * Unsubscribe SSH session events in @link TunnelSshConnectionSession.sshSessionDisposables */ unsubscribeSessionEvents() { this.sshSessionDisposables.forEach((d) => d.dispose()); this.sshSessionDisposables = []; } /** @internal */ getShortSessionId(session) { const b = session === null || session === void 0 ? void 0 : session.sessionId; if (!b || b.length < 16) { return ''; } // Format as a GUID. This cannot use uuid.stringify() because // the bytes might not be technically valid for a UUID. return b.subarray(0, 4).toString('hex') + '-' + b.subarray(4, 6).toString('hex') + '-' + b.subarray(6, 8).toString('hex') + '-' + b.subarray(8, 10).toString('hex') + '-' + b.subarray(10, 16).toString('hex'); } } exports.TunnelConnectionSession = TunnelConnectionSession; //# sourceMappingURL=tunnelConnectionSession.js.map