/** * @license * Copyright 2019 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { spawn, StdioOptions } from 'child_process'; import * as _ from 'lodash'; import { TypedError } from 'typed-error'; import { ExpectedError } from '../errors'; export class ExecError extends TypedError { public cmd: string; public exitCode: number; constructor(cmd: string, exitCode: number) { super(`Command '${cmd}' failed with error: ${exitCode}`); this.cmd = cmd; this.exitCode = exitCode; } } export async function exec( deviceIp: string, cmd: string, stdout?: NodeJS.WritableStream, ): Promise { const { which } = await import('./helpers'); const program = await which('ssh'); const args = [ '-n', '-t', '-p', '22222', '-o', 'LogLevel=ERROR', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', `root@${deviceIp}`, cmd, ]; if (process.env.DEBUG) { const logger = (await import('./logger')).getLogger(); logger.logDebug(`Executing [${program},${args}]`); } // Note: stdin must be 'inherit' to workaround a bug in older versions of // the built-in Windows 10 ssh client that otherwise prints the following // to stderr and hangs: "GetConsoleMode on STD_INPUT_HANDLE failed with 6" // They fixed the bug in newer versions of the ssh client: // https://github.com/PowerShell/Win32-OpenSSH/issues/856 // but users whould have to manually download and install a new client. // Note that "ssh -n" does not solve the problem, but should in theory // prevent the ssh client from using the CLI process stdin, even if it // is connected with 'inherit'. const stdio: StdioOptions = [ 'inherit', stdout ? 'pipe' : 'inherit', 'inherit', ]; const exitCode = await new Promise((resolve, reject) => { const ps = spawn(program, args, { stdio }) .on('error', reject) .on('close', resolve); if (stdout) { ps.stdout.pipe(stdout); } }); if (exitCode !== 0) { throw new ExecError(cmd, exitCode); } } export async function execBuffered( deviceIp: string, cmd: string, enc?: string, ): Promise { const through = await import('through2'); const buffer: string[] = []; await exec( deviceIp, cmd, through(function (data, _enc, cb) { buffer.push(data.toString(enc)); cb(); }), ); return buffer.join(''); } /** * Return a device's balenaOS release by executing 'cat /etc/os-release' * over ssh to the given deviceIp address. The result is cached with * lodash's memoize. */ export const getDeviceOsRelease = _.memoize(async (deviceIp: string) => execBuffered(deviceIp, 'cat /etc/os-release'), ); // TODO: consolidate the various forms of executing ssh child processes // in the CLI, like exec and spawn, starting with the files: // lib/actions/ssh.ts // lib/utils/ssh.ts // lib/utils/device/ssh.ts /** * Obtain the full path for ssh using which, then spawn a child process. * - If the child process returns error code 0, return the function normally * (do not throw an error). * - If the child process returns a non-zero error code, set process.exitCode * to that error code, and throw ExpectedError with a warning message. * - If the child process is terminated by a process signal, set * process.exitCode = 1, and throw ExpectedError with a warning message. */ export async function spawnSshAndThrowOnError( args: string[], options?: import('child_process').SpawnOptions, ) { const { whichSpawn } = await import('./helpers'); const [exitCode, exitSignal] = await whichSpawn( 'ssh', args, options, true, // returnExitCodeOrSignal ); if (exitCode || exitSignal) { // ssh returns a wide range of exit codes, including return codes of // interactive shells. For example, if the user types CTRL-C on an // interactive shell and then `exit`, ssh returns error code 130. // Another example, typing "exit 1" on an interactive shell causes ssh // to return exit code 1. In these cases, print a short one-line warning // message, and exits the CLI process with the same error code. process.exitCode = exitCode; throw new ExpectedError(sshErrorMessage(exitSignal, exitCode)); } } function sshErrorMessage(exitSignal?: string, exitCode?: number) { const msg: string[] = []; if (exitSignal) { msg.push(`Warning: ssh process was terminated with signal "${exitSignal}"`); } else { msg.push(`Warning: ssh process exited with non-zero code "${exitCode}"`); switch (exitCode) { case 255: msg.push(` Are the SSH keys correctly configured in balenaCloud? See: https://www.balena.io/docs/learn/manage/ssh-access/#add-an-ssh-key-to-balenacloud`); } } return msg.join('\n'); }