UNPKG

alwaysai

Version:

The alwaysAI command-line interface (CLI)

438 lines (400 loc) 12.7 kB
import { CliLeaf, CliNumberInput, CliStringInput, CliUsageError } from '@alwaysai/alwayscli'; import * as chalk from 'chalk'; import { Choice } from 'prompts'; import { yesCliInput } from '../../cli-inputs'; import { checkUserIsLoggedInComponent } from '../../components/user'; import { RequiredWithYesMessage, echo, logger, promptForInput } from '../../util'; import { DockerStartParams, OpenTunnelResponseBody, getDockerImageName, getSourceAccessToken, processOpenTunnelData, removeLocalhostPortsFromKnownHostsFile, secureTunnelJsonFile, startDockerLocalProxyContainer, startSecureTunnelSshSession, stopDockerLocalProxyContainer } from '../../util/secure-tunnel'; import { getDeviceList } from './list'; import { updateShadow, getSystemInfoShadow, getShadow, SecureTunnelPorts, HttpStatusCode } from '../../util/shadows'; import { gte } from 'semver'; import * as logSymbols from 'log-symbols'; // ---------------------------------------------------------------------------- // Local types and interface // ---------------------------------------------------------------------------- type ConnectedDevice = { uuid: string; friendlyName: string; }; // ---------------------------------------------------------------------------- // Local functions // ---------------------------------------------------------------------------- /** * Gets list of connected devices (uuid and device friendly name only) * @param {list} deviceList - list connected devices * @returns {ConnectedDevice[]} filters out disconnected devices and returns a list of connected devices */ const ipPortRegex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):([0-9]{1,5})$/; function getConnectedDevices(deviceList): ConnectedDevice[] { const connectedDevices = deviceList.filter( (device) => device.status === 'connected' ); return connectedDevices.map((device) => ({ uuid: device.uuid, friendlyName: device.friendly_name })); } /** * Prompts user to select the device to connect to * @param {ConnectedDevice[]} connectedDevices - list of connected devices * @returns {string} uuid of the selected device */ async function selectDeviceToConnect( connectedDevices: ConnectedDevice[] ): Promise<string> { const choices: Choice[] = [{ title: 'None', value: '' }]; connectedDevices.forEach((device) => { choices.push({ title: `${device.friendlyName}\t(${device.uuid})`, value: `${device.uuid}` }); }); const choice = await promptForInput({ purpose: 'to select target device to connect', questions: [ { type: 'select', name: 'targetDevice', message: chalk.yellowBright.bold( 'Select device to ssh to (disconnected devices are not listed)' ), initial: 0, choices } ] }); return choice.targetDevice; } /** * Prompts user to enter user name for the remote host * @returns {string} user name for the remote host */ async function getRemoteUserLogin(): Promise<string> { const remoteUserLoginAnswer = await promptForInput({ purpose: 'to set a remote user login', questions: [ { type: 'text', name: 'login', message: chalk.yellowBright.bold('Enter a remote user login') } ] }); return remoteUserLoginAnswer.login; } // ---------------------------------------------------------------------------- // Main function // ---------------------------------------------------------------------------- export const deviceConnect = CliLeaf({ name: 'connect', description: 'Connect to remote devices', namedInputs: { yes: yesCliInput, device: CliStringInput({ description: 'Device UUID' }), 'local-port': CliNumberInput({ description: 'Local port to use for connection' }), proxy: CliStringInput({ description: 'HTTP proxy in the format [ip:port]. Example: 100.70.31.118:80' }), username: CliStringInput({ description: 'Device username' }) }, hidden: true, async action(_, opts) { const { yes, device, username, proxy } = opts; const localPort = opts['local-port']; const deletedPorts = secureTunnelJsonFile().removeExpiredItems(); await removeLocalhostPortsFromKnownHostsFile(deletedPorts); if (yes) { if (device === undefined) { throw new CliUsageError(RequiredWithYesMessage('device', undefined)); } if (username === undefined) { throw new CliUsageError(RequiredWithYesMessage('username', undefined)); } } if (proxy !== undefined && !ipPortRegex.test(proxy)) { throw new CliUsageError( 'Invalid IP:Port format. Please enter a valid IP:Port' ); } await checkUserIsLoggedInComponent({ yes }); let selectedDeviceUuid = device; if (selectedDeviceUuid === undefined) { const deviceList = await getDeviceList(); logger.debug(JSON.stringify(deviceList, null, 4)); if (deviceList.length === 0) { return echo(chalk.redBright.bold('No devices found')); } const connectedDevices = getConnectedDevices(deviceList); logger.debug( `connectedDevices = ${JSON.stringify(connectedDevices, null, 4)}` ); if (connectedDevices.length === 0) { return echo(chalk.redBright.bold('No connected devices found')); } selectedDeviceUuid = await selectDeviceToConnect(connectedDevices); } logger.debug(`selectedDevice = ${selectedDeviceUuid}`); if (selectedDeviceUuid === '') { return echo(chalk.redBright.bold('No device selected')); } await checkDeviceAgentSecureTunnelCompatibility(selectedDeviceUuid, 'ssh'); //gather http ports let httpPorts: SecureTunnelPorts[] = []; if (proxy === undefined) { if (!yes) { httpPorts = await promptForHttpPortProxy(); } } else { httpPorts.push({ enabled: true, type: 'HTTP', ip: proxy.split(':')[0], port: parseInt(proxy.split(':')[1]) }); } if (httpPorts.length > 0) { await checkDeviceAgentSecureTunnelCompatibility( selectedDeviceUuid, 'portProxy' ); } const yesProxys = httpPorts.length > 0; const requiredProxys = yesProxys ? { SSH: 1, HTTP: 1 } : { SSH: 1, HTTP: 0 }; if (yesProxys) { const { payload } = await constructShadow(selectedDeviceUuid, httpPorts); const updatedShadow = await updateShadow(selectedDeviceUuid, payload); logger.debug(`updatedShadow = ${JSON.stringify(updatedShadow, null, 4)}`); } const openTunnelData: OpenTunnelResponseBody = yesProxys ? await getSourceAccessToken(selectedDeviceUuid, requiredProxys) : await getSourceAccessToken(selectedDeviceUuid); logger.debug(`openTunnelData = ${JSON.stringify(openTunnelData, null, 4)}`); const secureTunnelInfoFromAWS = processOpenTunnelData( openTunnelData, selectedDeviceUuid, yesProxys, localPort ? (_: string) => { return localPort; } : undefined ); if (secureTunnelInfoFromAWS.sourceAccessToken.length > 0) { const remoteUserLogin = username ? username : await getRemoteUserLogin(); logger.debug(`remoteUserLogin = ${remoteUserLogin}`); secureTunnelJsonFile().setItem( selectedDeviceUuid, secureTunnelInfoFromAWS ); const startParams: DockerStartParams = { deviceUuid: selectedDeviceUuid, imageName: getDockerImageName(), sshPort: secureTunnelInfoFromAWS.sshPort, httpPorts, sourceAccessToken: secureTunnelInfoFromAWS.sourceAccessToken }; await startDockerLocalProxyContainer(startParams); await startSecureTunnelSshSession( remoteUserLogin, secureTunnelInfoFromAWS.sshPort ); await removeLocalhostPortsFromKnownHostsFile([ secureTunnelInfoFromAWS.sshPort ]); await stopDockerLocalProxyContainer(selectedDeviceUuid); if (proxy || yesProxys) { const { payload } = await constructShadow( selectedDeviceUuid, httpPorts ); await disableAllPortsInShadow( payload.state.desired.st_ports, selectedDeviceUuid ); } } } }); async function promptForHttpPortProxy() { const answers = await promptForInput({ purpose: 'for http proxy initialization', questions: [ { type: 'confirm', name: 'value', message: chalk.yellowBright.bold('Add HTTP port proxy?'), initial: false }, { type: (prev) => (prev ? 'text' : null), name: 'port', message: chalk.yellowBright.bold('Enter the proxy IP address:port'), validate: (value) => { return ipPortRegex.test(value) ? true : 'Invalid IP:Port format. Please enter a valid IP:Port'; } } ] }); const httpPorts: SecureTunnelPorts[] = []; if (answers.port) { httpPorts.push({ enabled: true, type: 'HTTP', ip: answers.port.split(':')[0], port: parseInt(answers.port.split(':')[1]) }); } return httpPorts; } export async function constructShadow( selectedDeviceUuid: string, httpPorts: SecureTunnelPorts[] ) { const shadow = await getShadow(selectedDeviceUuid); let httpPortsOutput: SecureTunnelPorts[] = []; if ( shadow.payload.state?.reported && Object.keys(shadow.payload.state.reported).length > 0 ) { const { reported } = shadow.payload.state; // eslint-disable-next-line prefer-const let { foundHttp, httpPortsInput } = updatePorts(reported, httpPorts); //if the exact same http proxy is not found, add it if (!foundHttp) { httpPortsInput = addHttpProxies(httpPorts, httpPortsInput); } httpPortsOutput = [...httpPortsInput]; } else { httpPortsOutput = [ ...httpPorts, { enabled: true, type: 'SSH', ip: '0.0.0.0', port: 22 } ]; } const payload = { state: { desired: { st_ports: httpPortsOutput } }, ...(shadow && { version: shadow.payload.version }) }; return { payload }; } async function disableAllPortsInShadow( stPorts: SecureTunnelPorts[], selectedDeviceUuid: string ) { const shadow = await getShadow(selectedDeviceUuid); const disabledPorts = stPorts.map((port) => { port.enabled = false; return port; }); const payload = { state: { desired: { st_ports: disabledPorts } }, ...(shadow.payload.code === HttpStatusCode.OK && { version: shadow.payload.version }) }; await updateShadow(selectedDeviceUuid, payload); } export function updatePorts(reported: any, httpPorts: any[]) { let found = false; const httpPortsInput = (reported?.st_ports || []).map((port) => { httpPorts.forEach((ip) => { if (port.ip === ip.ip && port.port === ip.port) { port.enabled = true; found = true; } else if (port.type === 'HTTP' && port.enabled === true) { port.enabled = false; } }); if (port.type === 'SSH') { port.enabled = true; } return port; }); return { httpPortsInput, foundHttp: found }; } export function addHttpProxies( httpPorts: SecureTunnelPorts[], httpPortsInput: { enabled: boolean; type: string; ip: string; port: number }[] ) { httpPorts.forEach((ip) => { const proxy = { enabled: true, type: 'HTTP', ip: ip.ip, port: ip.port }; httpPortsInput.push(proxy); }); return httpPortsInput; } export type Feature = 'ssh' | 'portProxy'; export async function checkDeviceAgentSecureTunnelCompatibility( thingId: string, feature: Feature ) { const shadow = await getSystemInfoShadow(thingId); if (shadow.versions?.agent) { const agentVersion = shadow.versions.agent; if ( !IsFeatureSupported(SecureTunnelFeatureVersion[feature], agentVersion) ) { throw new CliUsageError( `The device agent version ${agentVersion} is not compatible with the feature: ${feature}. Please update the device agent to the minimum version ${SecureTunnelFeatureVersion[feature]}.` ); } } else { echo( `${logSymbols.warning} Cannot determine ${feature} support for device ${thingId}. Ensure your CLI and Device Agent are updated to the latest versions if connection fails.` ); } } enum SecureTunnelFeatureVersion { ssh = '0.2.0', portProxy = '1.4.0' } function IsFeatureSupported( featureVersion: string, currentVersion: string ): boolean { if (!featureVersion || !currentVersion) { return false; } return gte(currentVersion, featureVersion); }