mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-23 23:42:24 +00:00
634ad156ce
Change-type: patch
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
/**
|
|
* @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 type { StdioOptions } from 'child_process';
|
|
import { spawn } from 'child_process';
|
|
import * as _ from 'lodash';
|
|
|
|
import { ExpectedError } from '../errors';
|
|
|
|
export class SshPermissionDeniedError extends ExpectedError {}
|
|
|
|
export class RemoteCommandError extends ExpectedError {
|
|
cmd: string;
|
|
exitCode?: number;
|
|
exitSignal?: NodeJS.Signals;
|
|
|
|
constructor(cmd: string, exitCode?: number, exitSignal?: NodeJS.Signals) {
|
|
super(sshErrorMessage(cmd, exitSignal, exitCode));
|
|
this.cmd = cmd;
|
|
this.exitCode = exitCode;
|
|
this.exitSignal = exitSignal;
|
|
}
|
|
}
|
|
|
|
export interface SshRemoteCommandOpts {
|
|
cmd?: string;
|
|
hostname: string;
|
|
ignoreStdin?: boolean;
|
|
port?: number | 'cloud' | 'local';
|
|
proxyCommand?: string[];
|
|
username?: string;
|
|
verbose?: boolean;
|
|
}
|
|
|
|
export const stdioIgnore: {
|
|
stdin: 'ignore';
|
|
stdout: 'ignore';
|
|
stderr: 'ignore';
|
|
} = {
|
|
stdin: 'ignore',
|
|
stdout: 'ignore',
|
|
stderr: 'ignore',
|
|
};
|
|
|
|
export function sshArgsForRemoteCommand({
|
|
cmd = '',
|
|
hostname,
|
|
ignoreStdin = false,
|
|
port,
|
|
proxyCommand,
|
|
username = 'root',
|
|
verbose = false,
|
|
}: SshRemoteCommandOpts): string[] {
|
|
port = port === 'local' ? 22222 : port === 'cloud' ? 22 : port;
|
|
return [
|
|
...(verbose ? ['-vvv'] : []),
|
|
...(ignoreStdin ? ['-n'] : []),
|
|
'-t',
|
|
...(port ? ['-p', port.toString()] : []),
|
|
...['-o', 'LogLevel=ERROR'],
|
|
...['-o', 'StrictHostKeyChecking=no'],
|
|
...['-o', 'UserKnownHostsFile=/dev/null'],
|
|
...(proxyCommand && proxyCommand.length
|
|
? ['-o', `ProxyCommand=${proxyCommand.join(' ')}`]
|
|
: []),
|
|
`${username}@${hostname}`,
|
|
...(cmd ? [cmd] : []),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Execute the given command on a local balenaOS device over ssh.
|
|
* @param cmd Shell command to execute on the device
|
|
* @param hostname Device's hostname or IP address
|
|
* @param port SSH server TCP port number or 'local' (22222) or 'cloud' (22)
|
|
* @param stdin Readable stream to pipe to the remote command stdin,
|
|
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
|
|
* @param stdout Writeable stream to pipe from the remote command stdout,
|
|
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
|
|
* @param stderr Writeable stream to pipe from the remote command stdout,
|
|
* or 'ignore' or 'inherit' as documented in the child_process.spawn function.
|
|
* @param username SSH username for authorization. With balenaOS 2.44.0 or
|
|
* later, it can be a balenaCloud username.
|
|
* @param verbose Produce debugging output
|
|
*/
|
|
export async function runRemoteCommand({
|
|
cmd = '',
|
|
hostname,
|
|
port,
|
|
proxyCommand,
|
|
stdin = 'inherit',
|
|
stdout = 'inherit',
|
|
stderr = 'inherit',
|
|
username = 'root',
|
|
verbose = false,
|
|
}: SshRemoteCommandOpts & {
|
|
stdin?: 'ignore' | 'inherit' | NodeJS.ReadableStream;
|
|
stdout?: 'ignore' | 'inherit' | NodeJS.WritableStream;
|
|
stderr?: 'ignore' | 'inherit' | NodeJS.WritableStream;
|
|
}): Promise<void> {
|
|
let ignoreStdin: boolean;
|
|
if (stdin === 'ignore') {
|
|
// Set ignoreStdin=true in order for the "ssh -n" option to be used to
|
|
// prevent the ssh client from using the CLI process stdin. In addition,
|
|
// stdin must be forced to 'inherit' (if it is not a readable stream) in
|
|
// order to work around 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 actually fixed the bug in newer versions of the ssh client:
|
|
// https://github.com/PowerShell/Win32-OpenSSH/issues/856 but users
|
|
// have to manually download and install a new client.
|
|
ignoreStdin = true;
|
|
stdin = 'inherit';
|
|
} else {
|
|
ignoreStdin = false;
|
|
}
|
|
const { which } = await import('./which');
|
|
const program = await which('ssh');
|
|
const args = sshArgsForRemoteCommand({
|
|
cmd,
|
|
hostname,
|
|
ignoreStdin,
|
|
port,
|
|
proxyCommand,
|
|
username,
|
|
verbose,
|
|
});
|
|
|
|
if (process.env.DEBUG) {
|
|
const logger = (await import('./logger')).getLogger();
|
|
logger.logDebug(`Executing [${program},${args}]`);
|
|
}
|
|
|
|
const stdio: StdioOptions = [
|
|
typeof stdin === 'string' ? stdin : 'pipe',
|
|
typeof stdout === 'string' ? stdout : 'pipe',
|
|
typeof stderr === 'string' ? stderr : 'pipe',
|
|
];
|
|
let exitCode: number | undefined;
|
|
let exitSignal: NodeJS.Signals | undefined;
|
|
try {
|
|
[exitCode, exitSignal] = await new Promise((resolve, reject) => {
|
|
const ps = spawn(program, args, { stdio })
|
|
.on('error', reject)
|
|
.on('close', (code, signal) =>
|
|
resolve([code ?? undefined, signal ?? undefined]),
|
|
);
|
|
|
|
if (ps.stdin && stdin && typeof stdin !== 'string') {
|
|
stdin.pipe(ps.stdin);
|
|
}
|
|
if (ps.stdout && stdout && typeof stdout !== 'string') {
|
|
ps.stdout.pipe(stdout);
|
|
}
|
|
if (ps.stderr && stderr && typeof stderr !== 'string') {
|
|
ps.stderr.pipe(stderr);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const msg = [
|
|
`ssh failed with exit code=${exitCode} signal=${exitSignal}:`,
|
|
`[${program}, ${args.join(', ')}]`,
|
|
...(error ? [`${error}`] : []),
|
|
];
|
|
throw new ExpectedError(msg.join('\n'));
|
|
}
|
|
if (exitCode || exitSignal) {
|
|
throw new RemoteCommandError(cmd, exitCode, exitSignal);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute the given command on a local balenaOS device over ssh.
|
|
* Capture stdout and/or stderr to Buffers and return them.
|
|
*
|
|
* @param deviceIp IP address of the local device
|
|
* @param cmd Shell command to execute on the device
|
|
* @param opts Options
|
|
* @param opts.username SSH username for authorization. With balenaOS 2.44.0 or
|
|
* later, it may be a balenaCloud username. Otherwise, 'root'.
|
|
* @param opts.stdin Passed through to the runRemoteCommand function
|
|
* @param opts.stdout If 'capture', capture stdout to a Buffer.
|
|
* @param opts.stderr If 'capture', capture stdout to a Buffer.
|
|
*/
|
|
export async function getRemoteCommandOutput({
|
|
cmd,
|
|
hostname,
|
|
port,
|
|
proxyCommand,
|
|
stdin = 'ignore',
|
|
stdout = 'capture',
|
|
stderr = 'capture',
|
|
username = 'root',
|
|
verbose = false,
|
|
}: SshRemoteCommandOpts & {
|
|
stdin?: 'ignore' | 'inherit' | NodeJS.ReadableStream;
|
|
stdout?: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream;
|
|
stderr?: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream;
|
|
}): Promise<{ stdout: Buffer; stderr: Buffer }> {
|
|
const { Writable } = await import('stream');
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
const stdoutStream = new Writable({
|
|
write(chunk: Buffer, _enc, callback) {
|
|
stdoutChunks.push(chunk);
|
|
callback();
|
|
},
|
|
});
|
|
const stderrStream = new Writable({
|
|
write(chunk: Buffer, _enc, callback) {
|
|
stderrChunks.push(chunk);
|
|
callback();
|
|
},
|
|
});
|
|
await runRemoteCommand({
|
|
cmd,
|
|
hostname,
|
|
port,
|
|
proxyCommand,
|
|
stdin,
|
|
stdout: stdout === 'capture' ? stdoutStream : stdout,
|
|
stderr: stderr === 'capture' ? stderrStream : stderr,
|
|
username,
|
|
verbose,
|
|
});
|
|
return {
|
|
stdout: Buffer.concat(stdoutChunks),
|
|
stderr: Buffer.concat(stderrChunks),
|
|
};
|
|
}
|
|
|
|
/** Convenience wrapper for getRemoteCommandOutput */
|
|
export async function getLocalDeviceCmdStdout(
|
|
hostname: string,
|
|
cmd: string,
|
|
stdout: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream = 'capture',
|
|
): Promise<Buffer> {
|
|
const port = 'local';
|
|
return (
|
|
await getRemoteCommandOutput({
|
|
cmd,
|
|
hostname,
|
|
port,
|
|
stdout,
|
|
stderr: 'inherit',
|
|
username: await findBestUsernameForDevice(hostname, port),
|
|
})
|
|
).stdout;
|
|
}
|
|
|
|
/**
|
|
* Run a trivial 'exit 0' command over ssh on the target hostname (typically the
|
|
* IP address of a local device) with the 'root' username, in order to determine
|
|
* whether root authentication suceeds. It should succeed with development
|
|
* variants of balenaOS and fail with production variants, unless a ssh key was
|
|
* added to the device's 'config.json' file.
|
|
* @return True if succesful, false on any errors.
|
|
*/
|
|
export const isRootUserGood = _.memoize(async (hostname: string, port) => {
|
|
try {
|
|
await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
/**
|
|
* Determine whether the given local device (hostname or IP address) should be
|
|
* accessed as the 'root' user or as a regular cloud user (balenaCloud or
|
|
* openBalena). Where possible, the root user is preferable because:
|
|
* - It allows ssh to be used in air-gapped scenarios (no internet access).
|
|
* Logging in as a regular user requires the device to fetch public keys from
|
|
* the cloud backend.
|
|
* - Root authentication is significantly faster for local devices (a fraction
|
|
* of a second versus 5+ seconds).
|
|
* - Non-root authentication requires balenaOS v2.44.0 or later, so not (yet)
|
|
* universally possible.
|
|
*/
|
|
export const findBestUsernameForDevice = _.memoize(
|
|
async (hostname: string, port): Promise<string> => {
|
|
let username: string | undefined;
|
|
if (await isRootUserGood(hostname, port)) {
|
|
username = 'root';
|
|
} else {
|
|
const { getCachedUsername } = await import('./bootstrap');
|
|
username = (await getCachedUsername())?.username;
|
|
}
|
|
if (!username) {
|
|
const { stripIndent } = await import('./lazy');
|
|
throw new ExpectedError(stripIndent`
|
|
SSH authentication failed for 'root@${hostname}'.
|
|
Please login with 'balena login' for alternative authentication.`);
|
|
}
|
|
return username;
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 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 (hostname: string) =>
|
|
(await getLocalDeviceCmdStdout(hostname, 'cat /etc/os-release')).toString(),
|
|
);
|
|
|
|
function sshErrorMessage(cmd: string, exitSignal?: string, exitCode?: number) {
|
|
const msg: string[] = [];
|
|
cmd = cmd ? `Remote command "${cmd}"` : 'Process';
|
|
if (exitSignal) {
|
|
msg.push(`SSH: ${cmd} terminated with signal "${exitSignal}"`);
|
|
} else {
|
|
msg.push(`SSH: ${cmd} exited with non-zero status 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`);
|
|
msg.push('Are you accidentally using `sudo`?');
|
|
}
|
|
}
|
|
return msg.join('\n');
|
|
}
|