UNPKG

@microsoft/dev-tunnels-connections

Version:

Tunnels library for Visual Studio tools

565 lines 30.1 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. Object.defineProperty(exports, "__esModule", { value: true }); exports.TunnelRelayTunnelHost = 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 sshHelpers_1 = require("./sshHelpers"); const multiModeTunnelHost_1 = require("./multiModeTunnelHost"); const sessionPortKey_1 = require("./sessionPortKey"); const portRelayConnectRequestMessage_1 = require("./messages/portRelayConnectRequestMessage"); const portRelayConnectResponseMessage_1 = require("./messages/portRelayConnectResponseMessage"); const uuid_1 = require("uuid"); const sshHelpers_2 = require("./sshHelpers"); const tunnelConnectionSession_1 = require("./tunnelConnectionSession"); const webSocketSubProtocol = 'tunnel-relay-host'; const webSocketSubProtocolv2 = 'tunnel-relay-host-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' ? [webSocketSubProtocol] : protocolVersion === '2' ? [webSocketSubProtocolv2] : [webSocketSubProtocolv2, webSocketSubProtocol]; /** * Tunnel host implementation that uses data-plane relay * to accept client connections. */ class TunnelRelayTunnelHost extends tunnelConnectionSession_1.TunnelConnectionSession { constructor(managementClient, trace) { super(dev_tunnels_contracts_1.TunnelAccessScopes.Host, connectionProtocols, managementClient, trace); this.clientSessionPromises = []; this.reconnectableSessions = []; /** * Sessions created between this host and clients * @internal */ this.sshSessions = []; /** * Port Forwarders between host and clients */ this.remoteForwarders = new Map(); this.loopbackIp = '127.0.0.1'; this.forwardConnectionsToLocalPortsValue = (0, sshHelpers_2.isNode)(); const publicKey = dev_tunnels_ssh_1.SshAlgorithms.publicKey.ecdsaSha2Nistp384; if (publicKey) { this.hostPrivateKeyPromise = publicKey.generateKeyPair(); } this.hostId = multiModeTunnelHost_1.MultiModeTunnelHost.hostId; this.id = (0, uuid_1.v4)() + "-relay"; } get connectionId() { return this.hostId; } /** * A value indicating whether the port-forwarding service forwards connections to local TCP sockets. * Forwarded connections are not possible if the host is not NodeJS (e.g. browser). * The default value for NodeJS hosts is true. */ get forwardConnectionsToLocalPorts() { return this.forwardConnectionsToLocalPortsValue; } set forwardConnectionsToLocalPorts(value) { if (value === this.forwardConnectionsToLocalPortsValue) { return; } if (value && !(0, sshHelpers_2.isNode)()) { throw new Error('Cannot forward connections to local TCP sockets on this platform.'); } this.forwardConnectionsToLocalPortsValue = value; } /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. * @deprecated Use `connect()` instead. */ async start(tunnel) { await this.connect(tunnel); } /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. */ async connect(tunnel, options, cancellation) { await this.connectTunnelSession(tunnel, options, cancellation); } /** * 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) { if (this.disconnectReason === dev_tunnels_ssh_1.SshDisconnectReason.tooManyConnections) { // If another host for the same tunnel connects, the first connection is disconnected // with "too many connections" reason. Reconnecting it again would cause the second host to // be kicked out, and then it would try to reconnect, kicking out this one. // To prevent this tug of war, do not allow reconnection in this case. throw new dev_tunnels_ssh_1.SshConnectionError('Cannot retry connection because another host for this tunnel has connected. ' + 'Only one host connection at a time is supported.', dev_tunnels_ssh_1.SshDisconnectReason.tooManyConnections); } await super.connectTunnelSession(tunnel, options, cancellation); } /** * Configures the tunnel session with the given stream. * @internal */ async configureSession(stream, protocol, isReconnect, cancellation) { this.connectionProtocol = protocol; let session; if (this.connectionProtocol === webSocketSubProtocol) { // The V1 protocol always configures no security, equivalent to SSH MultiChannelStream. // The websocket transport is still encrypted and authenticated. session = new dev_tunnels_ssh_1.SshClientSession(new dev_tunnels_ssh_1.SshSessionConfiguration(false)); // no encryption } else { session = sshHelpers_1.SshHelpers.createSshClientSession((config) => { // 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); config.addService(dev_tunnels_ssh_tcp_1.PortForwardingService); }); const hostPfs = session.activateService(dev_tunnels_ssh_tcp_1.PortForwardingService); hostPfs.messageFactory = this; hostPfs.onForwardedPortConnecting(this.onForwardedPortConnecting, this, this.sshSessionDisposables); } session.onChannelOpening(this.hostSession_ChannelOpening, this, this.sshSessionDisposables); session.onClosed(this.onSshSessionClosed, this, this.sshSessionDisposables); session.trace = this.trace; session.onReportProgress((args) => this.raiseReportProgress(args.progress, args.sessionNumber), this, this.sshSessionDisposables); this.sshSession = session; await session.connect(stream, cancellation); // SSH authentication is skipped in V1 protocol, optional in V2 depending on whether the // session performed a key exchange (as indicated by having a session ID or not). In the // latter 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 (session.sessionId) { await session.authenticate({ username: 'tunnel' }); } if (this.connectionProtocol === webSocketSubProtocolv2) { // In the v2 protocol, the host starts "forwarding" the ports as soon as it connects. // Then the relay will forward the forwarded ports to clients as they connect. await this.startForwardingExistingPorts(session); } } /** * 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. * @internal */ async onConnectingToTunnel() { var _a, _b, _c, _d, _e; if (!this.hostPrivateKey || !this.hostPublicKeys) { if (!this.hostPrivateKeyPromise) { throw new Error('Cannot create host keys'); } this.hostPrivateKey = await this.hostPrivateKeyPromise; const buffer = await this.hostPrivateKey.getPublicKeyBytes(this.hostPrivateKey.keyAlgorithmName); if (!buffer) { throw new Error('Host private key public key bytes is not initialized'); } this.hostPublicKeys = [buffer.toString('base64')]; } const tunnelHasSshPort = ((_a = this.tunnel) === null || _a === void 0 ? void 0 : _a.ports) != null && this.tunnel.ports.find((v) => v.protocol === dev_tunnels_contracts_1.TunnelProtocol.Ssh); const endpointSignature = `${(_b = this.tunnel) === null || _b === void 0 ? void 0 : _b.tunnelId}.${(_c = this.tunnel) === null || _c === void 0 ? void 0 : _c.clusterId}:` + `${(_d = this.tunnel) === null || _d === void 0 ? void 0 : _d.name}.${(_e = this.tunnel) === null || _e === void 0 ? void 0 : _e.domain}:` + `${tunnelHasSshPort}:${this.hostId}:${this.hostPublicKeys}`; if (!this.relayUri || this.endpointSignature !== endpointSignature) { if (!this.tunnel) { throw new Error('Tunnel is required'); } let endpoint = { id: this.id, hostId: this.hostId, hostPublicKeys: this.hostPublicKeys, connectionMode: dev_tunnels_contracts_1.TunnelConnectionMode.TunnelRelay, }; let additionalQueryParameters = undefined; if (tunnelHasSshPort) { additionalQueryParameters = { includeSshGatewayPublicKey: 'true' }; } endpoint = await this.managementClient.updateTunnelEndpoint(this.tunnel, endpoint, { additionalQueryParameters: additionalQueryParameters, }); this.relayUri = endpoint.hostRelayUri; this.endpointSignature = endpointSignature; } } /** * Disposes this tunnel session, closing all client connections, the host SSH session, and deleting the endpoint. */ async dispose() { await super.dispose(); const promises = Object.assign([], this.clientSessionPromises); // No new client session should be added because the channel requests are rejected when the tunnel host is disposed. this.clientSessionPromises.length = 0; // If the tunnel is present, the endpoint was created, and this host was not closed because of // too many connections, delete the endpoint. // Too many connections closure means another host has connected, and that other host, while // connecting, would have updated the endpoint. So this host won't be able to delete it anyway. if (this.tunnel && this.endpointSignature && this.disconnectReason !== dev_tunnels_ssh_1.SshDisconnectReason.tooManyConnections) { const promise = this.managementClient.deleteTunnelEndpoints(this.tunnel, this.id); promises.push(promise); } for (const forwarder of this.remoteForwarders.values()) { forwarder.dispose(); } // When client session promises finish, they remove the sessions from this.sshSessions await Promise.all(promises); } hostSession_ChannelOpening(e) { if (!e.isRemoteRequest) { // Auto approve all local requests (not that there are any for the time being). return; } if (this.connectionProtocol === webSocketSubProtocolv2 && e.channel.channelType === 'forwarded-tcpip') { // With V2 protocol, the relay server always sends an extended channel open message // with a property indicating whether E2E encryption is requested for the connection. // The host returns an extended response message indicating if E2EE is enabled. const relayRequestMessage = e.channel.openMessage .convertTo(new portRelayConnectRequestMessage_1.PortRelayConnectRequestMessage()); const responseMessage = new portRelayConnectResponseMessage_1.PortRelayConnectResponseMessage(); // The host can enable encryption for the channel if the client requested it. responseMessage.isE2EEncryptionEnabled = this.enableE2EEncryption && relayRequestMessage.isE2EEncryptionRequested; // In the future the relay might send additional information in the connect // request message, for example a user identifier that would enable the host to // group channels by user. e.openingPromise = Promise.resolve(responseMessage); return; } else if (e.channel.channelType !== TunnelRelayTunnelHost.clientStreamChannelType) { e.failureDescription = `Unknown channel type: ${e.channel.channelType}`; e.failureReason = dev_tunnels_ssh_1.SshChannelOpenFailureReason.unknownChannelType; return; } // V1 protocol. // Increase max window size to work around channel congestion bug. // This does not entirely eliminate the problem, but reduces the chance. e.channel.maxWindowSize = dev_tunnels_ssh_1.SshChannel.defaultMaxWindowSize * 5; if (this.isDisposed) { e.failureDescription = 'The host is disconnecting.'; e.failureReason = dev_tunnels_ssh_1.SshChannelOpenFailureReason.connectFailed; return; } const promise = this.acceptClientSession(e.channel, this.disposeToken); this.clientSessionPromises.push(promise); // eslint-disable-next-line @typescript-eslint/no-floating-promises promise.then(() => { const index = this.clientSessionPromises.indexOf(promise); this.clientSessionPromises.splice(index, 1); }); } onForwardedPortConnecting(e) { const channel = e.stream.channel; const relayRequestMessage = channel.openMessage.convertTo(new portRelayConnectRequestMessage_1.PortRelayConnectRequestMessage()); const isE2EEncryptionEnabled = this.enableE2EEncryption && relayRequestMessage.isE2EEncryptionRequested; if (isE2EEncryptionEnabled) { // Increase the max window size so that it is at least larger than the window // size of one client channel. channel.maxWindowSize = dev_tunnels_ssh_1.SshChannel.defaultMaxWindowSize * 2; const serverCredentials = { publicKeys: [this.hostPrivateKey] }; const secureStream = new dev_tunnels_ssh_1.SecureStream(e.stream, serverCredentials, this.reconnectableSessions); secureStream.trace = this.trace; // The client was already authenticated by the relay. secureStream.onAuthenticating((authEvent) => authEvent.authenticationPromise = Promise.resolve({})); // The client will connect to the secure stream after the channel is opened. secureStream.connect().catch((err) => { this.trace(dev_tunnels_ssh_1.TraceLevel.Error, 0, `Error connecting encrypted channel: ${err}`); }); e.transformPromise = Promise.resolve(secureStream); } super.onForwardedPortConnecting(e); } async acceptClientSession(clientSessionChannel, cancellation) { try { const stream = new dev_tunnels_ssh_1.SshStream(clientSessionChannel); await this.connectAndRunClientSession(stream, cancellation); } catch (ex) { if (!(ex instanceof dev_tunnels_ssh_1.CancellationError) || !cancellation.isCancellationRequested) { this.trace(dev_tunnels_ssh_1.TraceLevel.Error, 0, `Error running client SSH session: ${ex}`); } } } /** * Creates an SSH server session for a client (V1 protocol), runs the session, * and waits for it to close. */ async connectAndRunClientSession(stream, cancellation) { if (cancellation.isCancellationRequested) { stream.destroy(); throw new dev_tunnels_ssh_1.CancellationError(); } const clientChannelId = stream.channel.channelId; const session = sshHelpers_1.SshHelpers.createSshServerSession(this.reconnectableSessions, (config) => { var _a; config.protocolExtensions.push(dev_tunnels_ssh_1.SshProtocolExtensionNames.sessionReconnect); config.addService(dev_tunnels_ssh_tcp_1.PortForwardingService); // 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; } }); session.trace = this.trace; session.onReportProgress((args) => this.raiseReportProgress(args.progress, args.sessionNumber), this, this.sshSessionDisposables); session.credentials = { publicKeys: [this.hostPrivateKey], }; const tcs = new dev_tunnels_ssh_1.PromiseCompletionSource(); const authenticatingEventRegistration = session.onAuthenticating((e) => { this.onSshClientAuthenticating(e); }); session.onClientAuthenticated(() => { // This call is async and will catch and log any async errors. void this.onSshClientAuthenticated(session); }); const requestRegistration = session.onRequest((e) => { this.onClientSessionRequest(e, session); }); const channelOpeningEventRegistration = session.onChannelOpening((e) => { this.onSshChannelOpening(e, session); }); const reconnectedEventRegistration = session.onReconnected(() => { this.onClientSessionReconnecting(session, clientChannelId); }); const closedEventRegistration = session.onClosed((e) => { this.onClientSessionClosed(session, e, clientChannelId, cancellation); tcs.resolve(); }); session.onKeepAliveFailed((count) => this.onKeepAliveFailed(count)); session.onKeepAliveSucceeded((count) => this.onKeepAliveSucceeded(count)); try { const nodeStream = new dev_tunnels_ssh_1.NodeStream(stream); await session.connect(nodeStream); this.sshSessions.push(session); cancellation.onCancellationRequested((e) => { tcs.reject(new dev_tunnels_ssh_1.CancellationError()); }); if (this.tunnel && this.managementClient) { const connectedEvent = { name: 'host_client_connect', properties: { ClientChannelId: clientChannelId.toString(), ClientSessionId: this.getShortSessionId(session), HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, connectedEvent); } await tcs.promise; } finally { authenticatingEventRegistration.dispose(); requestRegistration.dispose(); channelOpeningEventRegistration.dispose(); reconnectedEventRegistration.dispose(); closedEventRegistration.dispose(); await session.close(dev_tunnels_ssh_1.SshDisconnectReason.byApplication); session.dispose(); } } onSshClientAuthenticating(e) { if (e.authenticationType === dev_tunnels_ssh_1.SshAuthenticationType.clientNone) { // For now, the client is allowed to skip SSH authentication; // they must have a valid tunnel access token already to get this far. e.authenticationPromise = Promise.resolve({}); } else { // Other authentication types are not implemented. Doing nothing here // results in a client authentication failure. } } async onSshClientAuthenticated(session) { void this.startForwardingExistingPorts(session); } async startForwardingExistingPorts(session) { var _a, _b; const pfs = session.activateService(dev_tunnels_ssh_tcp_1.PortForwardingService); pfs.forwardConnectionsToLocalPorts = this.forwardConnectionsToLocalPorts; // Ports must be forwarded sequentially because the TS SSH lib // does not yet support concurrent requests. for (const port of (_b = (_a = this.tunnel) === null || _a === void 0 ? void 0 : _a.ports) !== null && _b !== void 0 ? _b : []) { this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Forwarding port ${port.portNumber}`); try { await this.forwardPort(pfs, port); } catch (ex) { this.traceError(`Error forwarding port ${port.portNumber}: ${ex}`); } } } onClientSessionRequest(e, session) { if (e.requestType === 'RefreshPorts') { e.responsePromise = (async () => { await this.refreshPorts(); return new dev_tunnels_ssh_1.SessionRequestSuccessMessage(); })(); } } onSshChannelOpening(e, session) { if (!(e.request instanceof dev_tunnels_ssh_tcp_1.PortForwardChannelOpenMessage)) { // This is to let the Go SDK open an unused session channel if (e.request.channelType === dev_tunnels_ssh_1.SshChannel.sessionChannelType) { return; } this.trace(dev_tunnels_ssh_1.TraceLevel.Warning, 0, 'Rejecting request to open non-portforwarding channel.'); e.failureReason = dev_tunnels_ssh_1.SshChannelOpenFailureReason.administrativelyProhibited; return; } const portForwardRequest = e.request; if (portForwardRequest.channelType === 'direct-tcpip') { if (!this.tunnel.ports.some((p) => p.portNumber === portForwardRequest.port)) { this.trace(dev_tunnels_ssh_1.TraceLevel.Warning, 0, 'Rejecting request to connect to non-forwarded port:' + portForwardRequest.port); e.failureReason = dev_tunnels_ssh_1.SshChannelOpenFailureReason.administrativelyProhibited; } } else if (portForwardRequest.channelType === 'forwarded-tcpip') { const eventArgs = new dev_tunnels_ssh_tcp_1.ForwardedPortConnectingEventArgs(portForwardRequest.port, false, new dev_tunnels_ssh_1.SshStream(e.channel)); super.onForwardedPortConnecting(eventArgs); } else { // For forwarded-tcpip do not check remoteForwarders because they may not be updated yet. // There is a small time interval in forwardPort() between the port // being forwarded with forwardFromRemotePort and remoteForwarders updated. // Setting PFS.acceptRemoteConnectionsForNonForwardedPorts to false makes PFS reject forwarding requests from the // clients for the ports that are not forwarded and are missing in PFS.remoteConnectors. // Call to pfs.forwardFromRemotePort() in forwardPort() adds the connector to PFS.remoteConnectors. this.trace(dev_tunnels_ssh_1.TraceLevel.Warning, 0, 'Nonrecognized channel type ' + portForwardRequest.channelType); e.failureReason = dev_tunnels_ssh_1.SshChannelOpenFailureReason.unknownChannelType; } } onClientSessionReconnecting(session, clientChannelId) { if (this.tunnel && this.managementClient) { const reconnectedEvent = { name: 'host_client_reconnect', properties: { ClientChannelId: clientChannelId.toString(), ClientSessionId: this.getShortSessionId(session), HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, reconnectedEvent); } } onClientSessionClosed(session, e, clientChannelId, cancellation) { // Determine severity based on the disconnect reason let severity; let details; // Reconnecting client session may cause the new session to close with 'None' reason. if (e.reason === dev_tunnels_ssh_1.SshDisconnectReason.byApplication) { details = 'Client ssh session closed by application.'; this.traceInfo(details); } else if (cancellation.isCancellationRequested) { details = 'Client ssh session cancelled.'; this.traceInfo(details); } else if (e.reason !== dev_tunnels_ssh_1.SshDisconnectReason.none) { severity = dev_tunnels_contracts_1.TunnelEvent.error; details = `Client ssh session closed unexpectedly due to ${e.reason}, ` + `"${e.message}"\n${e.error}`; this.traceError(details); } else { details = 'Client ssh session closed.'; } // Report client disconnected event if (this.tunnel && this.managementClient) { const disconnectedEvent = { timestamp: new Date(), name: 'host_client_disconnect', severity: severity, details: details, properties: { ClientChannelId: clientChannelId.toString(), ClientSessionId: this.getShortSessionId(session), HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, disconnectedEvent); } for (const [key, forwarder] of this.remoteForwarders.entries()) { if (forwarder.session === session) { forwarder.dispose(); this.remoteForwarders.delete(key); } } const index = this.sshSessions.indexOf(session); if (index >= 0) { this.sshSessions.splice(index, 1); } } async refreshPorts(cancellation) { var _a, _b; this.raiseReportProgress(dev_tunnels_contracts_1.TunnelProgress.StartingRefreshPorts); if (!await this.refreshTunnel(true, cancellation)) { return; } const ports = (_b = (_a = this.tunnel) === null || _a === void 0 ? void 0 : _a.ports) !== null && _b !== void 0 ? _b : []; let sessions = this.sshSessions; if (this.connectionProtocol === webSocketSubProtocolv2 && this.sshSession) { // In the V2 protocol, ports are forwarded directly on the host session. // (But even when the host is V2, some clients may still connect with V1.) sessions = [...sessions, this.sshSession]; } const forwardPromises = []; for (const port of ports) { // For all sessions which are connected and authenticated, forward any added/updated // ports. For sessions that are not yet authenticated, the ports will be forwarded // immediately after authentication completes - see onSshClientAuthenticated(). // (Session requests may not be sent before the session is authenticated, for sessions // that require authentication; For V2 sessions that are not encrypted/authenticated // at all, the session ID is null.) for (const session of sessions.filter((s) => s.isConnected && (!s.sessionId || s.principal))) { const key = new sessionPortKey_1.SessionPortKey(session.sessionId, Number(port.portNumber)); const forwarder = this.remoteForwarders.get(key.toString()); if (!forwarder) { const pfs = session.getService(dev_tunnels_ssh_tcp_1.PortForwardingService); forwardPromises.push(this.forwardPort(pfs, port)); } } } for (const [key, forwarder] of Object.entries(this.remoteForwarders)) { if (!ports.some((p) => p.portNumber === forwarder.localPort)) { this.remoteForwarders.delete(key); forwarder.dispose(); } } await Promise.all(forwardPromises); this.raiseReportProgress(dev_tunnels_contracts_1.TunnelProgress.CompletedRefreshPorts); } async forwardPort(pfs, port) { const portNumber = Number(port.portNumber); if (pfs.localForwardedPorts.find((p) => p.localPort === portNumber)) { // The port is already forwarded. This may happen if we try to add the same port twice after reconnection. return; } // When forwarding from a Remote port we assume that the RemotePortNumber // and requested LocalPortNumber are the same. const forwarder = await pfs.forwardFromRemotePort(this.loopbackIp, portNumber, 'localhost', portNumber); if (!forwarder) { // The forwarding request was rejected by the client. return; } const key = new sessionPortKey_1.SessionPortKey(pfs.session.sessionId, Number(forwarder.localPort)); this.remoteForwarders.set(key.toString(), forwarder); } } exports.TunnelRelayTunnelHost = TunnelRelayTunnelHost; TunnelRelayTunnelHost.webSocketSubProtocol = webSocketSubProtocol; TunnelRelayTunnelHost.webSocketSubProtocolv2 = webSocketSubProtocolv2; /** * Ssh channel type in host relay ssh session where client session streams are passed. */ TunnelRelayTunnelHost.clientStreamChannelType = 'client-ssh-session-stream'; //# sourceMappingURL=tunnelRelayTunnelHost.js.map