@microsoft/dev-tunnels-connections
Version:
Tunnels library for Visual Studio tools
431 lines • 23.2 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
Object.defineProperty(exports, "__esModule", { value: true });
exports.TunnelRelayTunnelClient = exports.webSocketSubProtocolv2 = exports.webSocketSubProtocol = void 0;
const dev_tunnels_contracts_1 = require("@microsoft/dev-tunnels-contracts");
const dev_tunnels_ssh_1 = require("@microsoft/dev-tunnels-ssh");
const dev_tunnels_ssh_tcp_1 = require("@microsoft/dev-tunnels-ssh-tcp");
const retryTcpListenerFactory_1 = require("./retryTcpListenerFactory");
const sshHelpers_1 = require("./sshHelpers");
const utils_1 = require("./utils");
const vscode_jsonrpc_1 = require("vscode-jsonrpc");
const portRelayConnectResponseMessage_1 = require("./messages/portRelayConnectResponseMessage");
const tunnelConnectionSession_1 = require("./tunnelConnectionSession");
const portForwardingEventArgs_1 = require("./portForwardingEventArgs");
exports.webSocketSubProtocol = 'tunnel-relay-client';
exports.webSocketSubProtocolv2 = 'tunnel-relay-client-v2-dev';
// Check for an environment variable to determine which protocol version to use.
// By default, prefer V2 and fall back to V1.
const protocolVersion = (process === null || process === void 0 ? void 0 : process.env) && process.env.DEVTUNNELS_PROTOCOL_VERSION;
const connectionProtocols = protocolVersion === '1' ? [exports.webSocketSubProtocol] :
protocolVersion === '2' ? [exports.webSocketSubProtocolv2] :
[exports.webSocketSubProtocolv2, exports.webSocketSubProtocol];
/**
* Tunnel client implementation that connects via a tunnel relay.
*/
class TunnelRelayTunnelClient extends tunnelConnectionSession_1.TunnelConnectionSession {
constructor(managementClient, trace) {
super(dev_tunnels_contracts_1.TunnelAccessScopes.Connect, connectionProtocols, managementClient, trace);
this.portForwardingEmitter = new vscode_jsonrpc_1.Emitter();
this.sshSessionClosedEmitter = new vscode_jsonrpc_1.Emitter();
this.acceptLocalConnectionsForForwardedPortsValue = (0, sshHelpers_1.isNode)();
this.localForwardingHostAddressValue = '127.0.0.1';
this.disconnectedStreams = new Map();
this.connectionModes = [];
/**
* Event raised when a port is about to be forwarded to the client.
*
* The application may cancel this event to prevent specific port(s) from being
* forwarded to the client. Cancelling prevents the tunnel client from listening on
* a local socket for the port, AND prevents use of {@link connectToForwardedPort}
* to open a direct stream connection to the port.
*/
this.portForwarding = this.portForwardingEmitter.event;
/**
* Extensibility point and unit test hook.
* This event fires when the client SSH session is disconnected or closed either by this client or Relay.
*/
this.sshSessionClosed = this.sshSessionClosedEmitter.event;
}
get isSshSessionActive() {
var _a;
return !!((_a = this.sshSession) === null || _a === void 0 ? void 0 : _a.isConnected);
}
/**
* Get a value indicating if remote port is forwarded and has any channels open on the client,
* whether used by local tcp listener if {AcceptLocalConnectionsForForwardedPorts} is true, or
* streamed via <see cref="ConnectToForwardedPortAsync(int, CancellationToken)"/>.
*/
hasForwardedChannels(port) {
var _a;
if (!this.isSshSessionActive) {
return false;
}
const pfs = (_a = this.sshSession) === null || _a === void 0 ? void 0 : _a.activateService(dev_tunnels_ssh_tcp_1.PortForwardingService);
const remoteForwardedPorts = pfs === null || pfs === void 0 ? void 0 : pfs.remoteForwardedPorts;
const forwardedPort = remoteForwardedPorts === null || remoteForwardedPorts === void 0 ? void 0 : remoteForwardedPorts.find((p) => p.remotePort === port);
return !!forwardedPort && remoteForwardedPorts.getChannels(forwardedPort).length > 0;
}
/**
* A value indicating whether local connections for forwarded ports are accepted.
* Local connections are not accepted if the host is not NodeJS (e.g. browser).
*/
get acceptLocalConnectionsForForwardedPorts() {
return this.acceptLocalConnectionsForForwardedPortsValue;
}
set acceptLocalConnectionsForForwardedPorts(value) {
if (value === this.acceptLocalConnectionsForForwardedPortsValue) {
return;
}
if (value && !(0, sshHelpers_1.isNode)()) {
throw new Error('Cannot accept local connections for forwarded ports on this platform.');
}
this.acceptLocalConnectionsForForwardedPortsValue = value;
this.configurePortForwardingService();
}
/**
* Gets the local network interface address that the tunnel client listens on when
* accepting connections for forwarded ports.
*/
get localForwardingHostAddress() {
return this.localForwardingHostAddressValue;
}
set localForwardingHostAddress(value) {
if (value !== this.localForwardingHostAddressValue) {
this.localForwardingHostAddressValue = value;
this.configurePortForwardingService();
}
}
get forwardedPorts() {
var _a;
const pfs = (_a = this.sshSession) === null || _a === void 0 ? void 0 : _a.activateService(dev_tunnels_ssh_tcp_1.PortForwardingService);
return pfs === null || pfs === void 0 ? void 0 : pfs.remoteForwardedPorts;
}
async connect(tunnel, options, cancellation) {
this.hostId = options === null || options === void 0 ? void 0 : options.hostId;
await this.connectTunnelSession(tunnel, options, cancellation);
}
tunnelChanged() {
var _a;
super.tunnelChanged();
this.endpoints = undefined;
if (this.tunnel) {
if (!this.tunnel.endpoints) {
throw new Error('Tunnel endpoints cannot be null');
}
if (this.tunnel.endpoints.length === 0) {
throw new Error('No hosts are currently accepting connections for the tunnel.');
}
const endpointGroups = utils_1.List.groupBy(this.tunnel.endpoints, (ep) => ep.hostId);
if (this.hostId) {
this.endpoints = endpointGroups.get(this.hostId);
if (!this.endpoints) {
throw new Error('The specified host is not currently accepting connections to the tunnel.');
}
}
else if (endpointGroups.size > 1) {
throw new Error('There are multiple hosts for the tunnel. Specify a host ID to connect to.');
}
else {
this.endpoints = (_a = endpointGroups.entries().next().value) === null || _a === void 0 ? void 0 : _a[1];
}
const tunnelEndpoints = this.endpoints.filter((ep) => ep.connectionMode === dev_tunnels_contracts_1.TunnelConnectionMode.TunnelRelay);
if (tunnelEndpoints.length === 0) {
throw new Error('The host is not currently accepting Tunnel relay connections.');
}
// TODO: What if there are multiple relay endpoints, which one should the tunnel client pick, or is this an error?
// For now, just chose the first one.
const endpoint = tunnelEndpoints[0];
this.hostPublicKeys = endpoint.hostPublicKeys;
this.relayUri = endpoint.clientRelayUri;
}
else {
this.relayUri = undefined;
}
}
onRequest(e) {
if (e.request.requestType === dev_tunnels_ssh_tcp_1.PortForwardingService.portForwardRequestType) {
// The event-handler has a chance to cancel forwarding of this port.
const request = e.request;
const args = new portForwardingEventArgs_1.PortForwardingEventArgs(request.port);
this.portForwardingEmitter.fire(args);
e.isAuthorized = !args.cancel;
}
else if (e.request.requestType === dev_tunnels_ssh_tcp_1.PortForwardingService.cancelPortForwardRequestType) {
e.isAuthorized = true;
}
}
/**
* Configures the tunnel session with the given stream.
* @internal
*/
async configureSession(stream, protocol, isReconnect, cancellation) {
this.connectionProtocol = protocol;
if (isReconnect && this.sshSession && !this.sshSession.isClosed) {
await this.sshSession.reconnect(stream, cancellation);
}
else {
await this.startSshSession(stream, cancellation);
}
}
startSshSession(stream, cancellation) {
return this.connectSession(async () => {
this.sshSession = sshHelpers_1.SshHelpers.createSshClientSession((config) => {
var _a;
// Enable port-forwarding via the SSH protocol.
config.addService(dev_tunnels_ssh_tcp_1.PortForwardingService);
if (this.connectionProtocol === exports.webSocketSubProtocol) {
// Enable client SSH session reconnect for V1 protocol only.
// (V2 SSH reconnect is handled by the SecureStream class.)
config.protocolExtensions.push(dev_tunnels_ssh_1.SshProtocolExtensionNames.sessionReconnect);
}
else {
// The V2 protocol configures optional encryption, including "none" as an enabled
// and preferred key-exchange algorithm, because encryption of the outer SSH
// session is optional since it is already over a TLS websocket.
config.keyExchangeAlgorithms.splice(0, 0, dev_tunnels_ssh_1.SshAlgorithms.keyExchange.none);
}
// Configure keep-alive if requested
const keepAliveInterval = (_a = this.connectionOptions) === null || _a === void 0 ? void 0 : _a.keepAliveIntervalInSeconds;
if (keepAliveInterval && keepAliveInterval > 0) {
config.keepAliveTimeoutInSeconds = keepAliveInterval;
}
});
this.sshSession.trace = this.trace;
this.sshSession.onReportProgress((args) => this.raiseReportProgress(args.progress, args.sessionNumber), this, this.sshSessionDisposables);
this.sshSession.onClosed(this.onSshSessionClosed, this, this.sshSessionDisposables);
this.sshSession.onAuthenticating(this.onSshServerAuthenticating, this, this.sshSessionDisposables);
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(dev_tunnels_ssh_tcp_1.PortForwardingService);
if (this.connectionProtocol === exports.webSocketSubProtocolv2) {
pfs.messageFactory = this;
pfs.onForwardedPortConnecting(this.onForwardedPortConnecting, this, this.sshSessionDisposables);
pfs.remoteForwardedPorts.onPortAdded((e) => this.onForwardedPortAdded(pfs, e), this, this.sshSessionDisposables);
pfs.remoteForwardedPorts.onPortUpdated((e) => this.onForwardedPortAdded(pfs, e), this, this.sshSessionDisposables);
}
this.configurePortForwardingService();
await this.sshSession.connect(stream, cancellation);
// SSH authentication is required in V1 protocol, optional in V2 depending on
// whether the session enabled key exchange (as indicated by having a session ID
// or not).In either case a password is not required. Strong authentication was
// already handled by the relay service via the tunnel access token used for the
// websocket connection.
if (this.sshSession.sessionId) {
// Use a snapshot of this.sshSession because if authenticate() fails, it closes the session,
// and onSshSessionClosed() clears up this.sshSession.
const session = this.sshSession;
const clientCredentials = { username: 'tunnel' };
if (!await session.authenticate(clientCredentials, cancellation)) {
throw new Error(session.principal ?
'SSH client authentication failed.' :
'SSH server authentication failed.');
}
}
});
}
configurePortForwardingService() {
const pfs = this.getSshSessionPfs();
if (!pfs) {
return;
}
// Do not start forwarding local connections for browser client connections or if this is not allowed.
if (this.acceptLocalConnectionsForForwardedPortsValue && (0, sshHelpers_1.isNode)()) {
pfs.tcpListenerFactory = new retryTcpListenerFactory_1.RetryTcpListenerFactory(this.localForwardingHostAddressValue);
}
else {
pfs.acceptLocalConnectionsForForwardedPorts = false;
}
}
onForwardedPortAdded(pfs, e) {
var _a, _b;
const port = e.port.remotePort;
if (typeof port !== 'number') {
return;
}
// If there are disconnected streams for the port, re-connect them now.
const disconnectedStreamsCount = (_b = (_a = this.disconnectedStreams.get(port)) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
for (let i = 0; i < disconnectedStreamsCount; i++) {
pfs.connectToForwardedPort(port)
.then(() => {
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Reconnected stream to fowarded port ${port}`);
}).catch((error) => {
this.trace(dev_tunnels_ssh_1.TraceLevel.Warning, 0, `Failed to reconnect to forwarded port ${port}: ${error}`);
// The host is no longer accepting connections on the forwarded port?
// Clear the list of disconnected streams for the port, because
// it seems it is no longer possible to reconnect them.
const streams = this.disconnectedStreams.get(port);
if (streams) {
while (streams.length > 0) {
streams.pop().dispose();
}
}
});
}
}
/**
* Invoked when a forwarded port is connecting. (Only for V2 protocol.)
*/
onForwardedPortConnecting(e) {
// With V2 protocol, the relay server always sends an extended response message
// with a property indicating whether E2E encryption is enabled for the connection.
const channel = e.stream.channel;
const relayResponseMessage = channel.openConfirmationMessage
.convertTo(new portRelayConnectResponseMessage_1.PortRelayConnectResponseMessage());
if (relayResponseMessage.isE2EEncryptionEnabled) {
// The host trusts the relay to authenticate the client, so it doesn't require
// any additional password/token for client authentication.
const clientCredentials = { username: "tunnel" };
e.transformPromise = new Promise((resolve, reject) => {
var _a;
// If there's a disconnected SecureStream for the port, try to reconnect it.
// If there are multiple, pick one and the host will match by SSH session ID.
let secureStream = (_a = this.disconnectedStreams.get(e.port)) === null || _a === void 0 ? void 0 : _a.shift();
if (secureStream) {
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Reconnecting encrypted stream for port ${e.port}...`);
secureStream.reconnect(e.stream)
.then(() => {
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Reconnecting encrypted stream for port ${e.port} succeeded.`);
resolve(secureStream);
}).catch(reject);
}
else {
secureStream = new dev_tunnels_ssh_1.SecureStream(e.stream, clientCredentials);
secureStream.trace = this.trace;
secureStream.onAuthenticating((authEvent) => authEvent.authenticationPromise =
this.onHostAuthenticating(authEvent).catch());
secureStream.onDisconnected(() => this.onSecureStreamDisconnected(e.port, secureStream));
// Do not pass the cancellation token from the connecting event,
// because the connection will outlive the event.
secureStream.connect().then(() => resolve(secureStream)).catch(reject);
}
});
}
super.onForwardedPortConnecting(e);
}
onSecureStreamDisconnected(port, secureStream) {
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Encrypted stream for port ${port} disconnected.`);
const streams = this.disconnectedStreams.get(port);
if (streams) {
streams.push(secureStream);
}
else {
this.disconnectedStreams.set(port, [secureStream]);
}
}
async onHostAuthenticating(e) {
var _a, _b;
if (e.authenticationType !== dev_tunnels_ssh_1.SshAuthenticationType.serverPublicKey || !e.publicKey) {
this.traceWarning('Invalid host authenticating event.');
return null;
}
// The public key property on this event comes from SSH key-exchange; at this point the
// SSH server has cryptographically proven that it holds the corresponding private key.
// Convert host key bytes to base64 to match the format in which the keys are published.
const hostKey = (_b = (_a = (await e.publicKey.getPublicKeyBytes(e.publicKey.keyAlgorithmName))) === null || _a === void 0 ? void 0 : _a.toString('base64')) !== null && _b !== void 0 ? _b : '';
// Host public keys are obtained from the tunnel endpoint record published by the host.
if (!this.hostPublicKeys) {
this.traceWarning('Host identity could not be verified because ' +
'no public keys were provided.');
this.traceVerbose(`Host key: ${hostKey}`);
return {};
}
if (this.hostPublicKeys.includes(hostKey)) {
this.traceVerbose(`Verified host identity with public key ${hostKey}`);
return {};
}
// The tunnel host may have reconnected with a different host public key.
// Try fetching the tunnel again to refresh the key.
if (!this.disposeToken.isCancellationRequested &&
await this.refreshTunnel(false, this.disposeToken) &&
this.hostPublicKeys.includes(hostKey)) {
this.traceVerbose('Verified host identity with public key ' + hostKey);
return {};
}
this.traceError('Host public key verification failed.');
this.traceVerbose(`Host key: ${hostKey}`);
this.traceVerbose(`Expected key(s): ${this.hostPublicKeys.join(', ')}`);
return null;
}
onSshServerAuthenticating(e) {
if (this.connectionProtocol === exports.webSocketSubProtocol) {
// For V1 protocol the SSH server is the host; it should be authenticated with public key.
e.authenticationPromise = this.onHostAuthenticating(e);
}
else {
// For V2 protocol the SSH server is the relay.
// Relay server authentication is done via the websocket TLS host certificate.
// If SSH encryption/authentication is used anyway, just accept any SSH host key.
e.authenticationPromise = Promise.resolve({});
}
}
async connectToForwardedPort(fowardedPort, cancellation) {
const pfs = this.getSshSessionPfs();
if (!pfs) {
throw new Error('Failed to connect to remote port. Ensure that the client has connected by calling connectClient.');
}
return pfs.connectToForwardedPort(fowardedPort, cancellation);
}
async waitForForwardedPort(forwardedPort, cancellation) {
const pfs = this.getSshSessionPfs();
if (!pfs) {
throw new Error('Port forwarding has not been started. Ensure that the client has connected by calling connectClient.');
}
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, 'Waiting for forwarded port ' + forwardedPort);
await pfs.waitForForwardedPort(forwardedPort, cancellation);
this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, 'Forwarded port ' + forwardedPort + ' is ready.');
}
getSshSessionPfs() {
var _a, _b;
return (_b = (_a = this.sshSession) === null || _a === void 0 ? void 0 : _a.getService(dev_tunnels_ssh_tcp_1.PortForwardingService)) !== null && _b !== void 0 ? _b : undefined;
}
async refreshPorts() {
if (!this.sshSession || this.sshSession.isClosed) {
throw new Error('Not connected.');
}
const request = new dev_tunnels_ssh_1.SessionRequestMessage();
request.requestType = 'RefreshPorts';
request.wantReply = true;
await this.sshSession.request(request);
}
/**
* @internal Closes the tunnel session due to an error.
*/
async closeSession(reason, error) {
if (this.isSshSessionActive) {
this.sshSessionClosedEmitter.fire(this);
}
await super.closeSession(reason, error);
}
/**
* SSH session closed event handler.
*/
onSshSessionClosed(e) {
this.sshSessionClosedEmitter.fire(this);
super.onSshSessionClosed(e);
}
onSshSessionDisconnected() {
this.sshSessionClosedEmitter.fire(this);
const reason = dev_tunnels_ssh_1.SshDisconnectReason.connectionLost;
const error = new dev_tunnels_ssh_1.SshConnectionError("Connection lost.", dev_tunnels_ssh_1.SshDisconnectReason.connectionLost);
this.maybeStartReconnecting(reason, undefined, error);
}
/**
* Connect to the tunnel session on the relay service using the given access token for authorization.
*/
async connectClientToRelayServer(clientRelayUri, accessToken) {
if (!clientRelayUri) {
throw new Error('Client relay URI must be a non-empty string');
}
this.relayUri = clientRelayUri;
this.accessToken = accessToken;
await this.connectTunnelSession();
}
}
exports.TunnelRelayTunnelClient = TunnelRelayTunnelClient;
TunnelRelayTunnelClient.webSocketSubProtocol = exports.webSocketSubProtocol;
TunnelRelayTunnelClient.webSocketSubProtocolv2 = exports.webSocketSubProtocolv2;
//# sourceMappingURL=tunnelRelayTunnelClient.js.map