UNPKG

alwaysai

Version:

The alwaysAI command-line interface (CLI)

513 lines (478 loc) 17.7 kB
import { CLI_TERSE_ERROR, CliTerseError } from '@alwaysai/alwayscli'; import { ConfigFileSchema } from '@alwaysai/config-nodejs'; import Ajv, { JSONSchemaType } from 'ajv'; import * as boxen from 'boxen'; import * as chalk from 'chalk'; import { join } from 'path'; import { v4 as uuidv4 } from 'uuid'; import { getSystemId } from '../infrastructure'; import { CliAuthenticationClient } from '../infrastructure/authentication-client'; import { serviceEndpointBuilder } from '../infrastructure/urls'; import { DOT_SSH_DIR, LOCAL_AAI_CFG_DIR } from '../paths'; import { Spinner, echo, logger, stringifyError } from './'; import { delay } from './delay'; import { SecureTunnelInteractiveSsh } from './secure-tunnel-ssh'; import { SecureTunnelPorts } from './shadows'; import { JsSpawner } from './spawner'; import { CodedError } from '@carnesen/coded-error'; // ---------------------------------------------------------------------------- // Local types and interface // ---------------------------------------------------------------------------- type SecureTunnelInfo = { tunnelId: string; sourceAccessToken: string; sshPort: number; expiresAt: number; }; export type SecureTunnelStorage = { setItem(key: string, value: SecureTunnelInfo): void; getItem(key: string): SecureTunnelInfo; getNextAvailablePort(): number; removeItem(key: string): void; removeExpiredItems(): number[]; }; type TunnelUsed = { by: string; since: number; }; export type OpenTunnelResponseBody = { expiresAt: number; newTunnelCreated: boolean; sourceAccessToken: string; txId: string; tunnelId: string; tunnelUsed?: TunnelUsed; }; enum HttpStatusCode { OK = 200, FORBIDDEN = 403, NOT_FOUND = 404, CONFLICT = 409 } export type DockerStartParams = { deviceUuid: string; sshPort: number; imageName: string; sourceAccessToken: string; httpPorts: SecureTunnelPorts[]; }; // ---------------------------------------------------------------------------- // Local constants // ---------------------------------------------------------------------------- const ST_FILE = join(LOCAL_AAI_CFG_DIR, 'secure-tunnels.json'); logger.debug(`ST_FILE: ${ST_FILE}`); logger.debug(`DOT_SSH_DIR: ${DOT_SSH_DIR}`); const SSH_KNOWN_HOSTS_FILE = join(DOT_SSH_DIR, 'known_hosts'); const SSH_INITIAL_PORT_NUMBER = 5010; const AAI_ORGANIZATION_NAME = 'alwaysai'; const AAI_DOCKER_IMAGE_NAME = 'securetunnel-localproxy'; const ENV_MAPPING = { development: '-dev', qa: '-qa', production: '' }; const AAI_DOCKER_HUB_SRC_PULL_TAG_DISTRO = 'ubuntu'; const AAI_DOCKER_HUB_SRC_PULL_TAG_DISTRO_VERSION = '20.04'; const AAI_DOCKER_HUB_SRC_PULL_TAG_RELEASE = 'latest'; const ST_DOCKER_STARTUP_DELAY = 1000; /** * The json schema is used to validate the contents of the secure-tunnel.json * file. */ const secureTunnelInfoType: JSONSchemaType<{ [key: string]: SecureTunnelInfo; }> = { type: 'object', patternProperties: { '^[a-fA-F0-9-]{36}$': { type: 'object', properties: { expiresAt: { type: 'number' }, sshPort: { type: 'number' }, sourceAccessToken: { type: 'string' }, tunnelId: { type: 'string' } }, required: ['expiresAt', 'sshPort', 'sourceAccessToken', 'tunnelId'] } }, required: [] }; const ajv = new Ajv(); export const secureTunnelValidateFunction = ajv.compile(secureTunnelInfoType); // ---------------------------------------------------------------------------- // functions // ---------------------------------------------------------------------------- /** * Contains functionality to read, update, and delete items in the secure-tunnel.json * @returns secure tunnel storage */ export function secureTunnelJsonFile(): SecureTunnelStorage { const configFile = ConfigFileSchema({ path: ST_FILE, validateFunction: secureTunnelValidateFunction, ENOENT: { code: CLI_TERSE_ERROR, message: `File not found ${ST_FILE}. Please ensure the ${LOCAL_AAI_CFG_DIR} folder exists in the home directory and has read and write access for the current user` }, EACCES: { code: CLI_TERSE_ERROR, message: `File not found ${ST_FILE}. Please ensure the ${LOCAL_AAI_CFG_DIR} folder exists in the home directory and has read and write access for the current user` }, initialValue: {} }); configFile.initialize(); const secureTunnelStorage: SecureTunnelStorage = { setItem(key: string, value: SecureTunnelInfo) { configFile.update((config) => { config[key] = value; }); }, getItem(key: string) { return configFile.read()[key] || {}; }, getNextAvailablePort() { let nextAvailablePort = SSH_INITIAL_PORT_NUMBER; const config = configFile.read() || {}; Object.keys(config).forEach((key) => { nextAvailablePort = Math.max(nextAvailablePort, config[key].sshPort); }); return nextAvailablePort + 1; }, removeItem(key: string) { configFile.update((config) => { delete config[key]; }); }, removeExpiredItems() { const deletedPorts: number[] = []; configFile.update((config) => { Object.keys(config).forEach((key) => { if (config[key].expiresAt < ((Date.now() / 1000) | 0)) { deletedPorts.push(config[key].sshPort); config[key].expiresAt < ((Date.now() / 1000) | 0) && delete config[key]; } }); }); return deletedPorts; } }; return secureTunnelStorage; } /** * Gets the docker image name for secure tunneling, * which depends on the dev, qa, prod environment. * @returns {string} full name for the docker image */ export function getDockerImageName() { const aaiEnv = getSystemId(); const stDockerImageNameExt = ENV_MAPPING[aaiEnv] || ''; const dockerImageName = `${AAI_ORGANIZATION_NAME}/${AAI_DOCKER_IMAGE_NAME}${stDockerImageNameExt}:${AAI_DOCKER_HUB_SRC_PULL_TAG_DISTRO}-${AAI_DOCKER_HUB_SRC_PULL_TAG_DISTRO_VERSION}-${AAI_DOCKER_HUB_SRC_PULL_TAG_RELEASE}`; logger.debug(`dockerImageName: ${dockerImageName}`); return dockerImageName; } /** * Removes previously added localhost with port numbers from known_hosts file * @param {number[]} sshPortList - list of ssh port numbers to be removed from known_hosts file */ export async function removeLocalhostPortsFromKnownHostsFile( sshPortList: number[] ) { if (sshPortList.length === 0) { return; } const spawner = JsSpawner(); if (!(await spawner.exists(SSH_KNOWN_HOSTS_FILE))) { return; } const knownHostsContent = await spawner.readFile(SSH_KNOWN_HOSTS_FILE); const filteredLines = knownHostsContent.split('\n').filter((line) => { for (const port of sshPortList) { if (line.startsWith(`[localhost]:${port}`)) { return false; } } return true; }); await spawner.writeFile(SSH_KNOWN_HOSTS_FILE, filteredLines.join('\n')); } /** * Gets source access token, needed to open secure tunnel * @param {string} deviceUuid - device uuid * @returns {Promise<OpenTunnelResponseBody>} source access token */ export async function getSourceAccessToken( deviceUuid: string, services?: { SSH: number; HTTP: number } ): Promise<OpenTunnelResponseBody> { const idTokenAuthorizationHeader = await CliAuthenticationClient().getIdAuthorizationHeader(); logger.debug( `idTokenAuthorizationHeader = ${JSON.stringify( idTokenAuthorizationHeader, null, 4 )}` ); const requestURL = serviceEndpointBuilder( 'secure-tunnel', 'openSecureTunnel' ); logger.debug(`requestURL = ${requestURL}`); const txUuid: string = uuidv4(); logger.debug(`txUuid = ${txUuid}`); const response = await fetch(requestURL, { method: 'post', body: JSON.stringify({ deviceUuid, txId: txUuid, ...(services && { services }) }), headers: { ...idTokenAuthorizationHeader, 'Content-Type': 'application/json' } }); if (response.status === HttpStatusCode.FORBIDDEN) { const errorMsg = `Error: Unauthorized, response.status = ${response.status}`; logger.error(errorMsg); throw new CodedError(errorMsg, response.status); } else if (response.status === HttpStatusCode.NOT_FOUND) { const errorMsg = `Error: Service not found, response.status = ${response.status}`; logger.error(errorMsg); throw new CodedError(errorMsg, response.status); } else if (response.status === HttpStatusCode.CONFLICT) { const errorMsg = `Error: There is currently a tunnel open for this device, response.status = ${HttpStatusCode.CONFLICT}\n If you recently closed a connection, please try again in a couple minutes.`; logger.error(errorMsg); } const readerStream = Buffer.from(await response.arrayBuffer()); const parsedJson = JSON.parse(readerStream.toString()); return parsedJson as OpenTunnelResponseBody; } /** * Starts a docker container with local proxy binary with parameters * @param {DockerStartParams} startParams - parameters for starting the docker container with local proxy */ export async function startDockerLocalProxyContainer( startParams: DockerStartParams ) { const spinner = Spinner('Start docker container'); const args = buildDockerCommand(startParams); logger.debug(`docker run ${args.join(' ')}`); try { await JsSpawner().run({ exe: 'docker', args: args.map((arg) => arg.toString()) }); await delay(ST_DOCKER_STARTUP_DELAY); spinner.succeed(); } catch (ex) { spinner.fail(`Command "docker run ${startParams.imageName}" failed`); logger.error(`Failed to start docker container: ${stringifyError(ex)}`); echo( chalk.redBright.bold(`Failed to start docker container: ${ex.message}`) ); echo(); echo('Please make sure you installed Docker and Docker engine is running'); echo( 'On Linux: Please double-check that you\'ve completed the "Manage Docker as a non-root user" post-install steps described here:' ); echo(' https://docs.docker.com/install/linux/linux-postinstall/'); echo(); throw new CliTerseError( `Failed to run "docker run ${startParams.imageName}"` ); } } /** * Stops docker container with local proxy binary * @param {string} containerName - docker container name to stop */ export async function stopDockerLocalProxyContainer(containerName: string) { const spinner = Spinner(`Stop docker container: ${containerName}`); try { await JsSpawner().run({ exe: 'docker', args: ['stop', containerName] }); spinner.succeed(); } catch (ex) { spinner.fail(`Command "docker stop ${containerName}" failed`); echo( chalk.redBright.bold(`Failed to stop docker container: ${ex.message}`) ); echo(); throw new CliTerseError( `Failed to stop docker container: ${containerName}` ); } } /** * Starts a ssh session with the remote host using localproxy binary running in the docker container * @param {string} remoteUser - remote host username * @param {number} sshPort - port number for ssh */ export async function startSecureTunnelSshSession( remoteUser: string, sshPort: number ) { logger.debug( `Starting ssh session with: ssh ${remoteUser}@localhost -p ${sshPort}` ); echo('Please enter the ssh password for remote host if prompted.'); try { const secureTunnelSsh = new SecureTunnelInteractiveSsh({ targetHost: `${remoteUser}@localhost`, sshPort }); const processedArgs = secureTunnelSsh.processArgs([]); await secureTunnelSsh.runInteractiveSshAsync(processedArgs); } catch (ex) { logger.error(`Failed to start ssh session: ${stringifyError(ex)}`); echo(chalk.redBright.bold(`Failed to start ssh session: ${ex.message}`)); echo(); } } /** * Display a message for the user with color * @param {string} msgToDisplay - message to display for the user * @param {chalk.Chalk} color - color of the message to display */ function displayMessageForUser(msgToDisplay: string, color: chalk.Chalk) { const colorMessage = color( boxen(msgToDisplay, { borderStyle: 'round', textAlignment: 'left', padding: 1 }) ); echo(colorMessage); } export function constructSecureTunnelMessage({ chargeOccurredMsg, tunnelId, expiresAtString = '', secureTunnelInfo, httpPortLocalProxyMessage = '' }) { return `${chargeOccurredMsg}: ${tunnelId}${expiresAtString}\n Please make sure the Docker engine is running before entering your remote user login. Please complete your SSH remote session with "exit", so that resources can be cleaned up. Happy Secure Tunneling!\n In case the SSH session does not start within 10 seconds, it is safe to assume that the target host is not capable of establishing a secure tunnel connection. The reason could be an outdated version of alwaysAI device-agent on the target or the target OS/architecture is not supported. In that case, please quit CLI with Ctrl+C and check the documentation for more details. Please also manually stop the running Docker container with: docker stop <device_uuid>\n To copy files to and from the device using Secure Tunnel, in a new terminal use port ${secureTunnelInfo.sshPort} like this:\n scp -P ${secureTunnelInfo.sshPort} <remote_username>@localhost:<remote_file_path> <local_destination_path>\n **NOTE:** Data transfer is throttled to 800Kbps bandwidth per secure tunnel limitations, so it may not be appropriate for large files.\n${httpPortLocalProxyMessage}`; } /** * Processes open tunnel data * @param {OpenTunnelResponseBody} openTunnelResp - open tunnel response data to process * @returns {SecureTunnelInfo} secure tunnel info */ export function processOpenTunnelData( openTunnelResp: OpenTunnelResponseBody, selectedDeviceUuid: string, httpPortRequired: boolean, getSshPortFn = getSshPort // DI, all other mocking failed for unit tests ): SecureTunnelInfo { const secureTunnelInfo: SecureTunnelInfo = { expiresAt: 0, sshPort: 0, sourceAccessToken: '', tunnelId: '' }; if (openTunnelResp) { logger.debug(`openTunnelData.expiresAt = ${openTunnelResp.expiresAt}`); const expiresAtString = openTunnelResp.expiresAt > 0 ? `, valid until: ${new Date( openTunnelResp.expiresAt * 1000 ).toLocaleString()}` : ''; logger.debug(`expiresAtString = ${expiresAtString}`); if (openTunnelResp.sourceAccessToken.length > 0) { // success! source access token available secureTunnelInfo.sourceAccessToken = openTunnelResp.sourceAccessToken; secureTunnelInfo.expiresAt = openTunnelResp.expiresAt; secureTunnelInfo.tunnelId = openTunnelResp.tunnelId; secureTunnelInfo.sshPort = getSshPortFn(selectedDeviceUuid); let httpPortLocalProxyMessage = ''; if (httpPortRequired) { const httpPort = secureTunnelInfo.sshPort + 1; httpPortLocalProxyMessage = `Starting docker container with local http proxy: http://localhost:${httpPort}`; } const chargeOccurredMsg = openTunnelResp.newTunnelCreated ? 'Charge occurred for opening new tunnel' : 'Re-using previously open tunnel'; const tunnelId = openTunnelResp.tunnelId; const msgToDisplay = constructSecureTunnelMessage({ chargeOccurredMsg, tunnelId, expiresAtString, secureTunnelInfo, httpPortLocalProxyMessage }); displayMessageForUser( msgToDisplay, openTunnelResp.newTunnelCreated ? chalk.yellowBright : chalk.greenBright ); } else { // failed! no source access token available let msgToDisplay = 'Failed to open Secure Tunnel'; if (openTunnelResp.tunnelUsed) { const usedBy: string = openTunnelResp.tunnelUsed.by.length > 0 ? `Used by: ${openTunnelResp.tunnelUsed.by}` : 'Used by: unknown'; const usedSince: string = openTunnelResp.tunnelUsed.since > 0 ? `, since: ${new Date( openTunnelResp.tunnelUsed.since * 1000 ).toLocaleString()}` : ''; const helpMsg = 'There is currently a tunnel open for this device. If you recently closed a connection, please try again in a couple minutes.'; msgToDisplay += `\n\n${usedBy}${usedSince}${expiresAtString}\n${helpMsg}`; } displayMessageForUser(msgToDisplay, chalk.redBright); } } return secureTunnelInfo; } function getSshPort(selectedDeviceUuid: string) { const secureTunnelInfoFromFile = secureTunnelJsonFile().getItem(selectedDeviceUuid); const sshPort = Object.keys(secureTunnelInfoFromFile).length > 0 ? secureTunnelInfoFromFile.sshPort : secureTunnelJsonFile().getNextAvailablePort(); return sshPort; } function buildDockerCommand(startParams: DockerStartParams) { const { sshPort, httpPorts, sourceAccessToken, imageName, deviceUuid } = startParams; const args = ['run', '--rm', '--detach', '--name', deviceUuid]; let sMap = `SSH=${sshPort}`; if (httpPorts && httpPorts.length > 0) { sMap = `SSH1=${sshPort}`; } const httpPortsArgs = httpPorts.map((_ip: any) => { const httpPort = (sshPort + 1).toString(); const args = ['--expose', `${httpPort}`, '-p', `${httpPort}:${httpPort}`]; sMap += `,HTTP1=${httpPort}`; return args; }); const sshArgs = ['--expose', `${sshPort}`, '-p', `${sshPort}:${sshPort}`]; const flattenedArgs = httpPortsArgs.flat(); const combinedPortArgs = [...sshArgs, ...flattenedArgs]; const combinedArgs = [ ...args, ...combinedPortArgs, imageName, '-s', sMap, '-t', sourceAccessToken ]; return combinedArgs; }