@microsoft/dev-tunnels-connections
Version:
Tunnels library for Visual Studio tools
655 lines • 28.9 kB
JavaScript
"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