UNPKG

alwaysai

Version:

The alwaysAI command-line interface (CLI)

395 lines 18.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.processOpenTunnelData = exports.constructSecureTunnelMessage = exports.startSecureTunnelSshSession = exports.stopDockerLocalProxyContainer = exports.startDockerLocalProxyContainer = exports.getSourceAccessToken = exports.removeLocalhostPortsFromKnownHostsFile = exports.getDockerImageName = exports.secureTunnelJsonFile = exports.secureTunnelValidateFunction = void 0; const alwayscli_1 = require("@alwaysai/alwayscli"); const config_nodejs_1 = require("@alwaysai/config-nodejs"); const ajv_1 = require("ajv"); const boxen = require("boxen"); const chalk = require("chalk"); const path_1 = require("path"); const uuid_1 = require("uuid"); const infrastructure_1 = require("../infrastructure"); const authentication_client_1 = require("../infrastructure/authentication-client"); const urls_1 = require("../infrastructure/urls"); const paths_1 = require("../paths"); const _1 = require("./"); const delay_1 = require("./delay"); const secure_tunnel_ssh_1 = require("./secure-tunnel-ssh"); const spawner_1 = require("./spawner"); const coded_error_1 = require("@carnesen/coded-error"); var HttpStatusCode; (function (HttpStatusCode) { HttpStatusCode[HttpStatusCode["OK"] = 200] = "OK"; HttpStatusCode[HttpStatusCode["FORBIDDEN"] = 403] = "FORBIDDEN"; HttpStatusCode[HttpStatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; HttpStatusCode[HttpStatusCode["CONFLICT"] = 409] = "CONFLICT"; })(HttpStatusCode || (HttpStatusCode = {})); // ---------------------------------------------------------------------------- // Local constants // ---------------------------------------------------------------------------- const ST_FILE = (0, path_1.join)(paths_1.LOCAL_AAI_CFG_DIR, 'secure-tunnels.json'); _1.logger.debug(`ST_FILE: ${ST_FILE}`); _1.logger.debug(`DOT_SSH_DIR: ${paths_1.DOT_SSH_DIR}`); const SSH_KNOWN_HOSTS_FILE = (0, path_1.join)(paths_1.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 = { 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_1.default(); exports.secureTunnelValidateFunction = ajv.compile(secureTunnelInfoType); // ---------------------------------------------------------------------------- // functions // ---------------------------------------------------------------------------- /** * Contains functionality to read, update, and delete items in the secure-tunnel.json * @returns secure tunnel storage */ function secureTunnelJsonFile() { const configFile = (0, config_nodejs_1.ConfigFileSchema)({ path: ST_FILE, validateFunction: exports.secureTunnelValidateFunction, ENOENT: { code: alwayscli_1.CLI_TERSE_ERROR, message: `File not found ${ST_FILE}. Please ensure the ${paths_1.LOCAL_AAI_CFG_DIR} folder exists in the home directory and has read and write access for the current user` }, EACCES: { code: alwayscli_1.CLI_TERSE_ERROR, message: `File not found ${ST_FILE}. Please ensure the ${paths_1.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 = { setItem(key, value) { configFile.update((config) => { config[key] = value; }); }, getItem(key) { 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) { configFile.update((config) => { delete config[key]; }); }, removeExpiredItems() { const deletedPorts = []; 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; } exports.secureTunnelJsonFile = secureTunnelJsonFile; /** * Gets the docker image name for secure tunneling, * which depends on the dev, qa, prod environment. * @returns {string} full name for the docker image */ function getDockerImageName() { const aaiEnv = (0, infrastructure_1.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}`; _1.logger.debug(`dockerImageName: ${dockerImageName}`); return dockerImageName; } exports.getDockerImageName = getDockerImageName; /** * 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 */ async function removeLocalhostPortsFromKnownHostsFile(sshPortList) { if (sshPortList.length === 0) { return; } const spawner = (0, spawner_1.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')); } exports.removeLocalhostPortsFromKnownHostsFile = removeLocalhostPortsFromKnownHostsFile; /** * Gets source access token, needed to open secure tunnel * @param {string} deviceUuid - device uuid * @returns {Promise<OpenTunnelResponseBody>} source access token */ async function getSourceAccessToken(deviceUuid, services) { const idTokenAuthorizationHeader = await (0, authentication_client_1.CliAuthenticationClient)().getIdAuthorizationHeader(); _1.logger.debug(`idTokenAuthorizationHeader = ${JSON.stringify(idTokenAuthorizationHeader, null, 4)}`); const requestURL = (0, urls_1.serviceEndpointBuilder)('secure-tunnel', 'openSecureTunnel'); _1.logger.debug(`requestURL = ${requestURL}`); const txUuid = (0, uuid_1.v4)(); _1.logger.debug(`txUuid = ${txUuid}`); const response = await fetch(requestURL, { method: 'post', body: JSON.stringify(Object.assign({ deviceUuid, txId: txUuid }, (services && { services }))), headers: Object.assign(Object.assign({}, idTokenAuthorizationHeader), { 'Content-Type': 'application/json' }) }); if (response.status === HttpStatusCode.FORBIDDEN) { const errorMsg = `Error: Unauthorized, response.status = ${response.status}`; _1.logger.error(errorMsg); throw new coded_error_1.CodedError(errorMsg, response.status); } else if (response.status === HttpStatusCode.NOT_FOUND) { const errorMsg = `Error: Service not found, response.status = ${response.status}`; _1.logger.error(errorMsg); throw new coded_error_1.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.`; _1.logger.error(errorMsg); } const readerStream = Buffer.from(await response.arrayBuffer()); const parsedJson = JSON.parse(readerStream.toString()); return parsedJson; } exports.getSourceAccessToken = getSourceAccessToken; /** * Starts a docker container with local proxy binary with parameters * @param {DockerStartParams} startParams - parameters for starting the docker container with local proxy */ async function startDockerLocalProxyContainer(startParams) { const spinner = (0, _1.Spinner)('Start docker container'); const args = buildDockerCommand(startParams); _1.logger.debug(`docker run ${args.join(' ')}`); try { await (0, spawner_1.JsSpawner)().run({ exe: 'docker', args: args.map((arg) => arg.toString()) }); await (0, delay_1.delay)(ST_DOCKER_STARTUP_DELAY); spinner.succeed(); } catch (ex) { spinner.fail(`Command "docker run ${startParams.imageName}" failed`); _1.logger.error(`Failed to start docker container: ${(0, _1.stringifyError)(ex)}`); (0, _1.echo)(chalk.redBright.bold(`Failed to start docker container: ${ex.message}`)); (0, _1.echo)(); (0, _1.echo)('Please make sure you installed Docker and Docker engine is running'); (0, _1.echo)('On Linux: Please double-check that you\'ve completed the "Manage Docker as a non-root user" post-install steps described here:'); (0, _1.echo)(' https://docs.docker.com/install/linux/linux-postinstall/'); (0, _1.echo)(); throw new alwayscli_1.CliTerseError(`Failed to run "docker run ${startParams.imageName}"`); } } exports.startDockerLocalProxyContainer = startDockerLocalProxyContainer; /** * Stops docker container with local proxy binary * @param {string} containerName - docker container name to stop */ async function stopDockerLocalProxyContainer(containerName) { const spinner = (0, _1.Spinner)(`Stop docker container: ${containerName}`); try { await (0, spawner_1.JsSpawner)().run({ exe: 'docker', args: ['stop', containerName] }); spinner.succeed(); } catch (ex) { spinner.fail(`Command "docker stop ${containerName}" failed`); (0, _1.echo)(chalk.redBright.bold(`Failed to stop docker container: ${ex.message}`)); (0, _1.echo)(); throw new alwayscli_1.CliTerseError(`Failed to stop docker container: ${containerName}`); } } exports.stopDockerLocalProxyContainer = stopDockerLocalProxyContainer; /** * 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 */ async function startSecureTunnelSshSession(remoteUser, sshPort) { _1.logger.debug(`Starting ssh session with: ssh ${remoteUser}@localhost -p ${sshPort}`); (0, _1.echo)('Please enter the ssh password for remote host if prompted.'); try { const secureTunnelSsh = new secure_tunnel_ssh_1.SecureTunnelInteractiveSsh({ targetHost: `${remoteUser}@localhost`, sshPort }); const processedArgs = secureTunnelSsh.processArgs([]); await secureTunnelSsh.runInteractiveSshAsync(processedArgs); } catch (ex) { _1.logger.error(`Failed to start ssh session: ${(0, _1.stringifyError)(ex)}`); (0, _1.echo)(chalk.redBright.bold(`Failed to start ssh session: ${ex.message}`)); (0, _1.echo)(); } } exports.startSecureTunnelSshSession = startSecureTunnelSshSession; /** * 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, color) { const colorMessage = color(boxen(msgToDisplay, { borderStyle: 'round', textAlignment: 'left', padding: 1 })); (0, _1.echo)(colorMessage); } 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}`; } exports.constructSecureTunnelMessage = constructSecureTunnelMessage; /** * Processes open tunnel data * @param {OpenTunnelResponseBody} openTunnelResp - open tunnel response data to process * @returns {SecureTunnelInfo} secure tunnel info */ function processOpenTunnelData(openTunnelResp, selectedDeviceUuid, httpPortRequired, getSshPortFn = getSshPort // DI, all other mocking failed for unit tests ) { const secureTunnelInfo = { expiresAt: 0, sshPort: 0, sourceAccessToken: '', tunnelId: '' }; if (openTunnelResp) { _1.logger.debug(`openTunnelData.expiresAt = ${openTunnelResp.expiresAt}`); const expiresAtString = openTunnelResp.expiresAt > 0 ? `, valid until: ${new Date(openTunnelResp.expiresAt * 1000).toLocaleString()}` : ''; _1.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 = openTunnelResp.tunnelUsed.by.length > 0 ? `Used by: ${openTunnelResp.tunnelUsed.by}` : 'Used by: unknown'; const usedSince = 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; } exports.processOpenTunnelData = processOpenTunnelData; function getSshPort(selectedDeviceUuid) { const secureTunnelInfoFromFile = secureTunnelJsonFile().getItem(selectedDeviceUuid); const sshPort = Object.keys(secureTunnelInfoFromFile).length > 0 ? secureTunnelInfoFromFile.sshPort : secureTunnelJsonFile().getNextAvailablePort(); return sshPort; } function buildDockerCommand(startParams) { 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) => { 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; } //# sourceMappingURL=secure-tunnel.js.map