@controlplane/cli
Version:
Control Plane Corporation CLI
622 lines • 31.1 kB
JavaScript
"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