UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

622 lines 31.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PortForwardCmd = void 0; const net = require("net"); const WebSocket = require("ws"); const command_1 = require("../cli/command"); const functions_1 = require("../util/functions"); const options_1 = require("./options"); const objects_1 = require("../util/objects"); const generic_1 = require("./generic"); const resolver_1 = require("./resolver"); const client_1 = require("../session/client"); const helpers_1 = require("../terminal-server/helpers"); const async_mutex_1 = require("async-mutex"); const auth_1 = require("../login/auth"); // ANCHOR - Constants const PORT_PATTERN = /^(6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})$/; const LOCALHOST_IPV4_ADDRESS = '127.0.0.1'; const LOCALHOST_IPV6_ADDRESS = '::1'; var PortForwardErrorType; (function (PortForwardErrorType) { PortForwardErrorType[PortForwardErrorType["Fatal"] = 0] = "Fatal"; PortForwardErrorType[PortForwardErrorType["TicketExpired"] = 1] = "TicketExpired"; })(PortForwardErrorType || (PortForwardErrorType = {})); // SECTION - Functions function withWorkload(yargs) { return yargs.positional('ref', { type: 'string', describe: 'The name of the workload to forward traffic to.', demandOption: true, }); } function withPorts(yargs) { return yargs.positional('ports', { type: 'string', describe: 'Port mappings in the format [LOCAL_PORT:]REMOTE_PORT (e.g. 8080:80).', array: true, demandOption: true, }); } function withPortForwardOptions(yargs) { return yargs.options({ address: { requiresArg: true, description: 'Addresses to listen on (comma separated). Only accepts IP addresses or localhost as a value. When localhost is supplied, cpln will try to bind on both 127.0.0.1 and ::1 and will fail if neither of these addresses are available to bind.', default: 'localhost', }, replica: { requiresArg: true, description: 'The name of the workload deployment replica', }, }); } // !SECTION // ANCHOR - Command class PortForwardCmd extends command_1.Command { constructor() { super(); this.command = 'port-forward <ref> <ports...>'; this.describe = 'Forward one or more local ports to a workload'; this.portRequestMap = new Map(); this.portMutexMap = new Map(); this.portTicketMap = new Map(); } // Public Methods // builder(yargs) { return (0, functions_1.pipe)( // withWorkload, withPorts, (0, generic_1.withSingleLocationOption)(false), withPortForwardOptions, options_1.withAllOptions)(yargs); } async handle(args) { // Format ports correctly args.ports = (0, objects_1.toArray)(args.ports); // Format address correctly const addresses = args.address.split(','); // Validate options await this.validate(args, addresses); // Deduplicate addresses const finalBindAddresses = this.deduplicateAddresses(addresses); // Default to first location if no location is specified args.location = await this.resolveLocation(args.location); // Create servers for each address and listen on ports await this.listenOnAddresses(args.ports, finalBindAddresses, args.ref, args.location, args.replica); } // Private Methods // /** * Iterates over the provided ports and addresses, setting up TCP servers * to listen on the specified addresses and forward traffic via WebSocket. * * @param ports - List of port mappings to listen on. * @param addresses - List of addresses to bind the listeners to. * @param workloadName - The workload name to forward traffic to. * @param location - The location in which the workload exists. * @param replica - Optional replica of the specified workload. */ async listenOnAddresses(ports, addresses, workloadName, location, replica) { // Get deployment remote endpoint using the specified location const remoteHost = await this.resolveDeploymentEndpoint(workloadName, location); // Create request configuration const config = await this.createConfig(remoteHost, workloadName, replica); // For each port mapping, parse and start a TCP server for (const mappingStr of ports) { let mapping; // Try to parse the port mapping string to an object try { mapping = this.parsePortMapping(mappingStr); } catch (e) { this.session.abort({ message: `Invalid port mapping "${mappingStr}": ${e.message}` }); } // Initialize the request object let request; // Use cached request or create a new one if (this.portRequestMap.has(mapping.remotePort)) { request = this.portRequestMap[mapping.remotePort]; } else { request = { ...config.request, port: mapping.remotePort, }; // Cache the request so it can be uniquely tied to the remote port this.portRequestMap.set(mapping.remotePort, request); } // Add a new record to the port mutex map so that we have a unique mutex for each remote port if (!this.portMutexMap.has(mapping.remotePort)) { this.portMutexMap.set(mapping.remotePort, new async_mutex_1.Mutex()); } // For each deduplicated bind address, create a TCP server for (const address of addresses) { // Resolve the actual bind address const bindAddress = this.resolveBindAddress(address); // Create a TCP server for this port mapping const server = net.createServer((localSocket) => { // Save the ticket for this connection to determine if a refresh is needed, // preventing simultaneous refresh attempts from multiple connections let connectionTicket = undefined; // Extract port number from the local socket const localPort = localSocket.address().port; // Log the connection this.session.out(`Handling connection for ${localPort}`); // Create a new WebSocket client const client = new WebSocket(config.remoteSocket, { origin: 'http://localhost' }); // Handle WebSocket events client.on('open', async () => { client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(config.request.token)); client_1.wire.debug('<<<<<<< WS Open Event Locking...'); // Use the mutex to prevent race conditions const release = await this.portMutexMap.get(mapping.remotePort).acquire(); // Create a ticket and attach it to the request object ONLY if it is not specified there if (!request.ticket) { // Create a ticket so we can pass it on every port-forward request const ticket = await this.resolveTicketForPort(remoteHost, workloadName, mapping.remotePort, replica); // Attach the ticket to the request object before sending it, // this way the ticket creation is done only once throughout the whole on-going connection to the specified remote port request.ticket = ticket; } // After creating a ticket, release the lock // If a waiting connection has the same remote port, it will use the new ticket release(); client_1.wire.debug('<<<<<<< WS Open Event Lock Released!'); // Set the connection ticket connectionTicket = request.ticket; // Initialize Request client.send(JSON.stringify(request)); // Handle local server events localSocket.on('data', (chunk) => { client.send(chunk); }); localSocket.on('error', (err) => { if (!err.message) { // If we are unable to extract the error message, then log the entire error object using console.error console.error(err); return; } // Log the error message to the user this.session.err(`Local server error during connection handling on port ${localPort}: ${err.message}`); }); }); client.on('error', (event) => { client_1.wire.debug('<<<<<<< WS Error Event info'); // Handle connection refused error differently if (event.hasOwnProperty('code') && event.code === 'ECONNREFUSED') { this.session.err(`error: connection refused by server, please try again.`); return; } // Show the error object for any other error this.session.err('error: lost connection to the server, details below:'); console.error(event); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event Triggered'); localSocket.end(); localSocket.destroy(); }); // Handle WebSocket events client.on('message', async (data) => { client_1.wire.debug('<<<<<<< WS Message Received'); // Check if the received message is representing an error const message = data.toString(); // Detect if there is a backend error const errorType = this.detectPortForwardError(message); // Handle each error type differently switch (errorType) { case PortForwardErrorType.TicketExpired: client_1.wire.debug('<<<<<<< WS Message Event Locking...'); // Lock so we avoid refreshing the ticket multiple times when there is a race condition const release = await this.portMutexMap.get(mapping.remotePort).acquire(); // After a lock is released, check if the ticket was refreshed. The way we will achieve this is by // comparing the connection's ticket (initially matching the request ticket) with the current request ticket. // If they differ, update the connection's ticket and return, indicating that a refresh is unnecessary if (connectionTicket != request.ticket) { connectionTicket = request.ticket; release(); return; } // Refresh the ticket for the specified request await this.refreshTicket(request, remoteHost, workloadName, mapping.remotePort, replica); // Unlock after refreshing the ticket release(); client_1.wire.debug('<<<<<<< WS Message Event Lock Released!'); return; case PortForwardErrorType.Fatal: // Signal the process to terminate on fatal errors this.session.abort({ message }); } // Write incoming data to the local socket if (typeof data === 'string') { localSocket.write(data); } else if (data instanceof Buffer) { localSocket.write(data); } }); }); // Determine local port: if not provided, use 0 (random) const localPortToBind = mapping.localPort !== undefined ? mapping.localPort : 0; // Listen on the specified port server.listen(localPortToBind, bindAddress, () => { // Extract address info const addressInfo = server.address(); // Extract the bind address let bindAddrStr = addressInfo.address; // Wrap IPv6 addresses with square brackets if (addressInfo.family === 'IPv6') { bindAddrStr = `[${bindAddrStr}]`; } // Log the forwarding information this.session.out(`Forwarding from ${bindAddrStr}:${addressInfo.port} -> ${mapping.remotePort}`); }); } } } /** * Create a port forward configuration object. * * @param {string} remoteHost The remote endpoint of the selected deployment. * @param {string} workloadName The workload name. * @param {string} replica The name of the workload deployment replica. * @returns {Promise<PortForwardConfig>} A promise that resolves to a PortForwardConfig object. */ async createConfig(remoteHost, workloadName, replica) { // Determine WebSocket endpoint const remoteSocket = `${remoteHost.replace('https://', 'wss://')}/portforward`; // Prepare token const token = (await (0, auth_1.makeAuthorizationHeader)(this.session.profile)).replace('Bearer ', ''); // Construct the result and return it return { remoteSocket: remoteSocket, request: { contextId: this.session.id, token, org: this.session.context.org, gvc: this.session.context.gvc, workload: workloadName, pod: replica, }, }; } /** * Resolves the provided location or defaults to the first available location within the GVC. * * @param location - The optional location to be resolved. * @returns The resolved location name. */ async resolveLocation(location) { var _a, _b; // If location is specified, no need to find one if (location) { return location; } // Resolve GVC link const gvcSelfLink = (0, resolver_1.resolveToLink)('gvc', this.session.context.gvc, this.session.context); // Fetch GVC const gvc = await this.client.get(gvcSelfLink); // Determine location self link let locationSelfLink = ''; // Extract the first location out of the GVC, abort if GVC has no locations if (((_b = (_a = gvc.spec) === null || _a === void 0 ? void 0 : _a.staticPlacement) === null || _b === void 0 ? void 0 : _b.locationLinks) && gvc.spec.staticPlacement.locationLinks.length > 0) { locationSelfLink = gvc.spec.staticPlacement.locationLinks[0]; } else { this.session.abort({ message: 'ERROR: Gvc has no locations' }); } // Extract location name from location self link location = locationSelfLink.split('/location/')[1]; // Log the location that was picked this.session.err((0, helpers_1.defaultingToMessage)('location', location)); // Return the location that has been picked return location; } /** * Retrieves the remote deployment endpoint for a given workload in a specified location. * * @param workloadSelfLink - The self-link of the workload. * @param location - The location of the deployment. * @returns The remote host endpoint of the deployment. */ async resolveDeploymentEndpoint(workloadName, location) { // Use the remote host from an environment variable if specified if (process.env.TERMINAL_SERVER_URL) { return process.env.TERMINAL_SERVER_URL; } // Construct workload self link const workloadSelfLink = (0, resolver_1.kindResolver)('workload').resourceLink(workloadName, this.session.context); // Determine deployment self link const deploymentSelfLink = `${workloadSelfLink}/deployment/${location}`; // Fetch deployment const deployment = await this.client.get(deploymentSelfLink); // Abort if the deployment is invalid if (!deployment.status || !deployment.status.remote) { this.session.abort({ message: 'ERROR: No deployment is present in the workload.' }); } // Extract and return remote host endpoint from deployment status return deployment.status.remote; } /** * Resolves a port-forwarding ticket for a given port, either by retrieving an * existing ticket or creating a new one. * * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. * @returns {string} A promise that resolves to the port-forwarding ticket. */ async resolveTicketForPort(remoteHost, workloadName, port, replica) { // Use the cached ticket for the remote port if it has been created before if (this.portTicketMap.has(port)) { return this.portTicketMap.get(port); } // Create the ticket const ticket = await this.createTicket(remoteHost, workloadName, port, replica); // Cache and store the ticket so we don't have to create another ticket for the same port this.portTicketMap.set(port, ticket); // Return the ticket return ticket; } /** * Creates a new ticket, assigns it to the request object and updates the port ticket map. * * @param {PortForwardRequest} request - The request object that is being used to establish the port-forwarding connection. * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. */ async refreshTicket(request, remoteHost, workloadName, port, replica) { client_1.wire.debug(`<<<<<<< Port-forward refreshing ticket for port: ${port}`); // Create a new ticket const ticket = await this.createTicket(remoteHost, workloadName, port, replica); // Update the request object so on future connections, the new ticket will be used request.ticket = ticket; // Update the port ticket mapping so on future connections, the new ticket will be used this.portTicketMap[port] = ticket; } /** * Creates a port-forwarding ticket for a given port. * * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. * @returns {string} A promise that resolves to the port-forwarding ticket. */ async createTicket(remoteHost, workloadName, port, replica) { // Construct the request object const requestBody = { org: this.session.context.org, gvc: this.session.context.gvc, workload: workloadName, pod: replica, port, }; // Try to create a ticket, abort on failure try { // Make a POST request const response = await this.client.post(`${remoteHost}/portforward-ticket`, requestBody); // Extract the ticket const ticket = response.ticket; // Show the ticket in the debug logs client_1.wire.debug('<<<<<<< Port-forward ticket created: ' + (0, client_1.convertAuthTokenForDebug)(ticket)); // Return the ticket return ticket; } catch (e) { this.session.err(`ERROR: Failed to create ticket, details:`); this.session.abort({ error: e }); } } /** * Detect if the message indicates a port-forward error, and return the error type * * @param {string} message The WebSocket message. * @returns {PortForwardErrorType | null} The error type to handle later. */ detectPortForwardError(message) { // The port-forward error starts with the following, if it is not found then there are no errors if (!message.startsWith('Port-forward error:')) { return null; } // Attempt to extract a JSON error object from the message; default to fatal on failure try { // Extract the JSON from the error message const portForwardError = JSON.parse(this.extractPortForwardErrorJson(message)); // Log the message before returning the error type client_1.wire.debug(`Handling port-forward error: ${portForwardError.message}. code: ${portForwardError.code}`); // Determine the error type from the error code if (portForwardError.code === PortForwardErrorType.TicketExpired) { return PortForwardErrorType.TicketExpired; } // For every other case, return fatal type for now return PortForwardErrorType.Fatal; } catch (e) { // Log the error for debugging if necessary client_1.wire.debug('ERROR: Failed to extract/parse port-forward error JSON:', e); // Default to fatal, this is how the terminal server is signaling the CLI to simply die return PortForwardErrorType.Fatal; } } /** * Parses a port mapping string into an object containing local and remote ports. * * @param {string} mapping - The port mapping string in the format "LOCAL:REMOTE", ":REMOTE", or "REMOTE". * @returns {PortMapping} An object containing the localPort (if specified) and remotePort. */ parsePortMapping(mapping) { // Split the input string by ':' to separate local and remote ports const parts = mapping.split(':'); // If there's only one part, it must be a remote port if (parts.length === 1) { // Check if the single part is a valid port number if (!PORT_PATTERN.test(parts[0])) { this.session.abort({ message: `Invalid port value: ${mapping}` }); } // Extract the port const targetPort = parseInt(parts[0], 10); // Return an object with only the remotePort property return { localPort: targetPort, remotePort: targetPort }; } // If there are two parts, it could be a valid "LOCAL:REMOTE" format else if (parts.length === 2) { // Destructure local and remote port values from the array const [localStr, remoteStr] = parts; // Ensure the remote port is valid and not empty if (remoteStr === '' || !PORT_PATTERN.test(remoteStr)) { this.session.abort({ message: `Invalid remote port in mapping: ${mapping}` }); } // Convert the remote port string to a number const remotePort = parseInt(remoteStr, 10); // Initialize localPort as undefined by default let localPort = undefined; // If a local port is specified, validate it if (localStr !== '') { if (!PORT_PATTERN.test(localStr)) { this.session.abort({ message: `Invalid local port in mapping: ${mapping}` }); } // Convert the local port string to a number localPort = parseInt(localStr, 10); } // Return an object containing both localPort and remotePort return { localPort, remotePort }; } // If there are more than two parts, the format is invalid this.session.abort({ message: `Invalid port mapping format: ${mapping}` }); } /** * Resolves and returns a valid bind address based on the input address. * * @param {string} address - The address string that needs to be resolved. * @returns {string} The trimmed address, which can be used for binding a server. */ resolveBindAddress(address) { // Trim any leading or trailing whitespace from the address string const trimmed = address.trim(); // Return the processed address return trimmed; } /** * Removes duplicate addresses from the provided list, ensuring that * 'localhost' is mapped to both IPv4 (127.0.0.1) and IPv6 (::1). * * @param addresses - The list of addresses to deduplicate. * @returns A unique list of addresses with duplicates removed. */ deduplicateAddresses(addresses) { // Prepare for collecting unique addresses const uniqueBindAddresses = new Set(); // Iterate over each address and deduplicate to avoid duplicates if they are already provided for (const address of addresses) { // If "localhost" is specified, add both "127.0.0.1" and "::1" if (address === 'localhost') { uniqueBindAddresses.add(LOCALHOST_IPV4_ADDRESS); uniqueBindAddresses.add(LOCALHOST_IPV6_ADDRESS); } else { uniqueBindAddresses.add(address); } } // Convert the set to an array for iteration return Array.from(uniqueBindAddresses); } /** * Validates the provided arguments and addresses. * * @param args - The command arguments containing port-forward options. * @param addresses - The list of addresses to validate. * @returns A Promise that resolves when validation is complete. */ async validate(args, addresses) { // Validate org if (!this.session.context.org) { this.session.abort({ message: 'ERROR: An organization is required. Please, specify one using the --org option.' }); } // Validate gvc if (!this.session.context.gvc) { this.session.abort({ message: 'ERROR: A GVC is required. Please, specify one using the --gvc option.' }); } // Validate ports if (!this.arePortsValid(args.ports)) { this.session.abort({ message: 'Invalid port mapping. Each port must be between 1 and 65535. Correct format: "[LOCAL_PORT:]REMOTE_PORT". Example: "8080:80".', }); } // Validate address if (!this.areAddressesValid(addresses)) { this.session.abort({ message: 'An invalid address option was specified, an address can only be a valid IP address or the value localhost.', }); } } /** * Validates an array of port mappings to ensure they follow the correct format. * * @param {string[]} ports - An array of port mapping strings. * @returns {boolean} True if all port mappings are valid, false otherwise. */ arePortsValid(ports) { // Iterate over each port mapping and validate it for (const portMapping of ports) { // If the ports are separated by a colon, then separate them const parts = portMapping.split(':'); // Handle different cases if (parts.length === 1) { // Single port case (REMOTE_PORT) if (!PORT_PATTERN.test(parts[0])) { return false; } } else if (parts.length === 2) { // Port mapping case (LOCAL_PORT:REMOTE_PORT) const [localPort, remotePort] = parts; // Empty localPort (":80") is valid (random local port) if (localPort !== '' && !PORT_PATTERN.test(localPort)) { return false; } // Validate remote port if (!PORT_PATTERN.test(remotePort)) { return false; } } else { // Invalid format (e.g., "8080:80:443") return false; } } // If all ports are valid, return true return true; } /** * Validates an array of addresses to ensure they are either "localhost" or a valid IP address. * * @param {string[]} addresses - An array of address strings to validate. * @returns {boolean} True if all addresses are valid, false otherwise. */ areAddressesValid(addresses) { // Iterate over each address specified and check if it is invalid for (const address of addresses) { // If it is not localhost and it is not even a valid IP address, return false if (address != 'localhost' && net.isIP(address) == 0) { return false; } } // All specified addresses are valid return true; } /** * Extracts the JSON portion from an error message string. * * @param errorMessage - The full port-forward error message that contains a JSON object. * @returns The JSON portion of the error message as a string, or an empty string if not found. */ extractPortForwardErrorJson(errorMessage) { // Find the index of the first '{' character which indicates the start of the JSON object const jsonStartIndex = errorMessage.indexOf('{'); // If found, extract and return the JSON string; otherwise, return an empty string return jsonStartIndex !== -1 ? errorMessage.substring(jsonStartIndex) : ''; } } exports.PortForwardCmd = PortForwardCmd; //# sourceMappingURL=portforward.js.map