"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=https://github.com/VSCodium/sourcemaps/releases/download/stable-4c0b0c6cc561d2d3636d1ec250935431876ce4dc/node_modules-@microsoft-dev-tunnels-connections-tunnelRelayTunnelClient.js.map