/** * @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 * as Bluebird from 'bluebird'; import { spawn, StdioOptions } from 'child_process'; import * as _ from 'lodash'; import { TypedError } from 'typed-error'; 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 Bluebird((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 call process.exit()). * - If the child process returns a non-zero error code, print a single-line * warning message and call process.exit(code) with the same non-zero error * code. * - If the child process is terminated by a process signal, print a * single-line warning message and call process.exit(1). */ export async function spawnSshAndExitOnError( 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. const codeMsg = exitSignal ? `was terminated with signal "${exitSignal}"` : `exited with non-zero code "${exitCode}"`; console.error(`Warning: ssh process ${codeMsg}`); // TODO: avoid process.exit by refactoring CLI error handling to allow // exiting with an error code and single-line warning "without a fuss" // about contacting support and filing Github issues. (ExpectedError // does not currently devlivers that.) process.exit(exitCode || 1); } }