UNPKG

@microsoft/dev-tunnels-connections

Version:

Tunnels library for Visual Studio tools

431 lines 23.2 kB
"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