mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-30 10:38:50 +00:00
Merge pull request #2457 from balena-io/2292-ssh-username
ssh: Attempt cloud username if 'root' authentication fails
This commit is contained in:
commit
43cddd2e5d
@ -230,19 +230,15 @@ export default class SshCmd extends Command {
|
|||||||
} else {
|
} else {
|
||||||
accessCommand = `host ${deviceUuid}`;
|
accessCommand = `host ${deviceUuid}`;
|
||||||
}
|
}
|
||||||
|
const { runRemoteCommand } = await import('../utils/ssh');
|
||||||
const command = this.generateVpnSshCommand({
|
await runRemoteCommand({
|
||||||
uuid: deviceUuid,
|
cmd: accessCommand,
|
||||||
command: accessCommand,
|
hostname: `ssh.${proxyUrl}`,
|
||||||
verbose: options.verbose,
|
|
||||||
port: options.port,
|
port: options.port,
|
||||||
proxyCommand,
|
proxyCommand,
|
||||||
proxyUrl: proxyUrl || '',
|
username,
|
||||||
username: username!,
|
verbose: options.verbose,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { spawnSshAndThrowOnError } = await import('../utils/ssh');
|
|
||||||
return spawnSshAndThrowOnError(command);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerId(
|
async getContainerId(
|
||||||
@ -295,53 +291,28 @@ export default class SshCmd extends Command {
|
|||||||
containerId = body.services[serviceName];
|
containerId = body.services[serviceName];
|
||||||
} else {
|
} else {
|
||||||
console.error(stripIndent`
|
console.error(stripIndent`
|
||||||
Using legacy method to detect container ID. This will be slow.
|
Using slow legacy method to determine container IDs. To speed up
|
||||||
To speed up this process, please update your device to an OS
|
this process, update the device supervisor to v8.6.0 or later.
|
||||||
which has a supervisor version of at least v8.6.0.
|
`);
|
||||||
`);
|
|
||||||
// We need to execute a balena ps command on the device,
|
// We need to execute a balena ps command on the device,
|
||||||
// and parse the output, looking for a specific
|
// and parse the output, looking for a specific
|
||||||
// container
|
// container
|
||||||
const childProcess = await import('child_process');
|
|
||||||
const { escapeRegExp } = await import('lodash');
|
const { escapeRegExp } = await import('lodash');
|
||||||
const { which } = await import('../utils/which');
|
|
||||||
const { deviceContainerEngineBinary } = await import(
|
const { deviceContainerEngineBinary } = await import(
|
||||||
'../utils/device/ssh'
|
'../utils/device/ssh'
|
||||||
);
|
);
|
||||||
|
const { getRemoteCommandOutput } = await import('../utils/ssh');
|
||||||
|
|
||||||
const sshBinary = await which('ssh');
|
const containers: string = (
|
||||||
const sshArgs = this.generateVpnSshCommand({
|
await getRemoteCommandOutput({
|
||||||
uuid,
|
cmd: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
|
||||||
verbose: false,
|
hostname: `ssh.${sshOpts.proxyUrl}`,
|
||||||
port: sshOpts.port,
|
port: sshOpts.port,
|
||||||
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
|
proxyCommand: sshOpts.proxyCommand,
|
||||||
proxyCommand: sshOpts.proxyCommand,
|
stderr: 'inherit',
|
||||||
proxyUrl: sshOpts.proxyUrl,
|
username: sshOpts.username,
|
||||||
username: sshOpts.username,
|
})
|
||||||
});
|
).stdout.toString();
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error(`[debug] [${sshBinary}, ${sshArgs.join(', ')}]`);
|
|
||||||
}
|
|
||||||
const subProcess = childProcess.spawn(sshBinary, sshArgs, {
|
|
||||||
stdio: [null, 'pipe', null],
|
|
||||||
});
|
|
||||||
const containers = await new Promise<string>((resolve, reject) => {
|
|
||||||
const output: string[] = [];
|
|
||||||
subProcess.stdout.on('data', (chunk) => output.push(chunk.toString()));
|
|
||||||
subProcess.on('close', (code: number) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Non-zero error code when looking for service container: ${code}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
resolve(output.join(''));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = containers.split('\n');
|
const lines = containers.split('\n');
|
||||||
const regex = new RegExp(`\\/?${escapeRegExp(serviceName)}_\\d+_\\d+`);
|
const regex = new RegExp(`\\/?${escapeRegExp(serviceName)}_\\d+_\\d+`);
|
||||||
for (const container of lines) {
|
for (const container of lines) {
|
||||||
@ -360,28 +331,4 @@ export default class SshCmd extends Command {
|
|||||||
}
|
}
|
||||||
return containerId;
|
return containerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateVpnSshCommand(opts: {
|
|
||||||
uuid: string;
|
|
||||||
command: string;
|
|
||||||
verbose: boolean;
|
|
||||||
port?: number;
|
|
||||||
username: string;
|
|
||||||
proxyUrl: string;
|
|
||||||
proxyCommand?: string[];
|
|
||||||
}) {
|
|
||||||
return [
|
|
||||||
...(opts.verbose ? ['-vvv'] : []),
|
|
||||||
'-t',
|
|
||||||
...['-o', 'LogLevel=ERROR'],
|
|
||||||
...['-o', 'StrictHostKeyChecking=no'],
|
|
||||||
...['-o', 'UserKnownHostsFile=/dev/null'],
|
|
||||||
...(opts.proxyCommand && opts.proxyCommand.length
|
|
||||||
? ['-o', `ProxyCommand=${opts.proxyCommand.join(' ')}`]
|
|
||||||
: []),
|
|
||||||
...(opts.port ? ['-p', opts.port.toString()] : []),
|
|
||||||
`${opts.username}@ssh.${opts.proxyUrl}`,
|
|
||||||
opts.command,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -16,12 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as packageJSON from '../package.json';
|
import * as packageJSON from '../package.json';
|
||||||
import { getBalenaSdk, stripIndent } from './utils/lazy';
|
import { stripIndent } from './utils/lazy';
|
||||||
|
|
||||||
interface CachedUsername {
|
|
||||||
token: string;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track balena CLI usage events (product improvement analytics).
|
* Track balena CLI usage events (product improvement analytics).
|
||||||
@ -49,40 +44,13 @@ export async function trackCommand(commandSignature: string) {
|
|||||||
scope.setExtra('command', commandSignature);
|
scope.setExtra('command', commandSignature);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const settings = await import('balena-settings-client');
|
const { getCachedUsername } = await import('./utils/bootstrap');
|
||||||
|
let username: string | undefined;
|
||||||
const username = await (async () => {
|
try {
|
||||||
const getStorage = await import('balena-settings-storage');
|
username = (await getCachedUsername())?.username;
|
||||||
const dataDirectory = settings.get<string>('dataDirectory');
|
} catch {
|
||||||
const storage = getStorage({ dataDirectory });
|
// ignore
|
||||||
let token;
|
}
|
||||||
try {
|
|
||||||
token = await storage.get('token');
|
|
||||||
} catch {
|
|
||||||
// If we can't get a token then we can't get a username
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = (await storage.get('cachedUsername')) as CachedUsername;
|
|
||||||
if (result.token === token) {
|
|
||||||
return result.username;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const balena = getBalenaSdk();
|
|
||||||
const $username = await balena.auth.whoami();
|
|
||||||
await storage.set('cachedUsername', {
|
|
||||||
token,
|
|
||||||
username: $username,
|
|
||||||
} as CachedUsername);
|
|
||||||
return $username;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!process.env.BALENARC_NO_SENTRY) {
|
if (!process.env.BALENARC_NO_SENTRY) {
|
||||||
Sentry!.configureScope((scope) => {
|
Sentry!.configureScope((scope) => {
|
||||||
scope.setUser({
|
scope.setUser({
|
||||||
@ -96,6 +64,7 @@ export async function trackCommand(commandSignature: string) {
|
|||||||
!process.env.BALENA_CLI_TEST_TYPE &&
|
!process.env.BALENA_CLI_TEST_TYPE &&
|
||||||
!process.env.BALENARC_NO_ANALYTICS
|
!process.env.BALENARC_NO_ANALYTICS
|
||||||
) {
|
) {
|
||||||
|
const settings = await import('balena-settings-client');
|
||||||
const balenaUrl = settings.get<string>('balenaUrl');
|
const balenaUrl = settings.get<string>('balenaUrl');
|
||||||
await sendEvent(balenaUrl, `[CLI] ${commandSignature}`, username);
|
await sendEvent(balenaUrl, `[CLI] ${commandSignature}`, username);
|
||||||
}
|
}
|
||||||
|
@ -119,3 +119,61 @@ export async function pkgExec(modFunc: string, args: string[]) {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedUsername {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUsername: CachedUsername | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the parsed contents of the `~/.balena/cachedUsername` file. If the file
|
||||||
|
* does not exist, create it with the details from the cloud. If not connected
|
||||||
|
* to the internet, return undefined. This function is used by `lib/events.ts`
|
||||||
|
* (event tracking) and `lib/utils/device/ssh.ts` and needs to gracefully handle
|
||||||
|
* the scenario of not being connected to the internet.
|
||||||
|
*/
|
||||||
|
export async function getCachedUsername(): Promise<CachedUsername | undefined> {
|
||||||
|
if (cachedUsername) {
|
||||||
|
return cachedUsername;
|
||||||
|
}
|
||||||
|
const [{ getBalenaSdk }, getStorage, settings] = await Promise.all([
|
||||||
|
import('./lazy'),
|
||||||
|
import('balena-settings-storage'),
|
||||||
|
import('balena-settings-client'),
|
||||||
|
]);
|
||||||
|
const dataDirectory = settings.get<string>('dataDirectory');
|
||||||
|
const storage = getStorage({ dataDirectory });
|
||||||
|
let token: string | undefined;
|
||||||
|
try {
|
||||||
|
token = (await storage.get('token')) as string | undefined;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
// If we can't get a token then we can't get a username
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = (await storage.get('cachedUsername')) as
|
||||||
|
| CachedUsername
|
||||||
|
| undefined;
|
||||||
|
if (result && result.token === token && result.username) {
|
||||||
|
cachedUsername = result;
|
||||||
|
return cachedUsername;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const username = await getBalenaSdk().auth.whoami();
|
||||||
|
if (username) {
|
||||||
|
cachedUsername = { token, username };
|
||||||
|
await storage.set('cachedUsername', cachedUsername);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore (not connected to the internet?)
|
||||||
|
}
|
||||||
|
return cachedUsername;
|
||||||
|
}
|
||||||
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import type { ContainerInfo } from 'dockerode';
|
import type { ContainerInfo } from 'dockerode';
|
||||||
|
|
||||||
|
import { ExpectedError } from '../../errors';
|
||||||
import { stripIndent } from '../lazy';
|
import { stripIndent } from '../lazy';
|
||||||
|
|
||||||
export interface DeviceSSHOpts {
|
export interface DeviceSSHOpts {
|
||||||
@ -26,76 +28,80 @@ export interface DeviceSSHOpts {
|
|||||||
|
|
||||||
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
|
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the running containers on the device with dockerode, and return the
|
||||||
|
* container ID that matches the given service name.
|
||||||
|
*/
|
||||||
|
async function getContainerIdForService(
|
||||||
|
service: string,
|
||||||
|
deviceAddress: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const { escapeRegExp, reduce } = await import('lodash');
|
||||||
|
const Docker = await import('dockerode');
|
||||||
|
const docker = new Docker({
|
||||||
|
host: deviceAddress,
|
||||||
|
port: 2375,
|
||||||
|
});
|
||||||
|
const regex = new RegExp(`(^|\\/)${escapeRegExp(service)}_\\d+_\\d+`);
|
||||||
|
const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
|
||||||
|
let allContainers: ContainerInfo[];
|
||||||
|
try {
|
||||||
|
allContainers = await docker.listContainers();
|
||||||
|
} catch (_e) {
|
||||||
|
throw new ExpectedError(stripIndent`
|
||||||
|
Could not access docker daemon on device ${deviceAddress}.
|
||||||
|
Please ensure the device is in local mode.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceNames: string[] = [];
|
||||||
|
const containers: Array<{ id: string; name: string }> = [];
|
||||||
|
for (const container of allContainers) {
|
||||||
|
for (const name of container.Names) {
|
||||||
|
if (regex.test(name)) {
|
||||||
|
containers.push({ id: container.Id, name });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const match = name.match(nameRegex);
|
||||||
|
if (match) {
|
||||||
|
serviceNames.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (containers.length > 1) {
|
||||||
|
throw new ExpectedError(stripIndent`
|
||||||
|
Found more than one container matching service name "${service}":
|
||||||
|
${containers.map((container) => container.name).join(', ')}
|
||||||
|
Use different service names to avoid ambiguity.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
const containerId = containers.length ? containers[0].id : '';
|
||||||
|
if (!containerId) {
|
||||||
|
throw new ExpectedError(
|
||||||
|
`Could not find a service on device with name ${service}. ${
|
||||||
|
serviceNames.length > 0
|
||||||
|
? `Available services:\n${reduce(
|
||||||
|
serviceNames,
|
||||||
|
(str, name) => `${str}\t${name}\n`,
|
||||||
|
'',
|
||||||
|
)}`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return containerId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function performLocalDeviceSSH(
|
export async function performLocalDeviceSSH(
|
||||||
opts: DeviceSSHOpts,
|
opts: DeviceSSHOpts,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { escapeRegExp, reduce } = await import('lodash');
|
let cmd = '';
|
||||||
const { spawnSshAndThrowOnError } = await import('../ssh');
|
|
||||||
const { ExpectedError } = await import('../../errors');
|
|
||||||
|
|
||||||
let command = '';
|
if (opts.service) {
|
||||||
|
const containerId = await getContainerIdForService(
|
||||||
|
opts.service,
|
||||||
|
opts.address,
|
||||||
|
);
|
||||||
|
|
||||||
if (opts.service != null) {
|
|
||||||
// Get the containers which are on-device. Currently we
|
|
||||||
// are single application, which means we can assume any
|
|
||||||
// container which fulfills the form of
|
|
||||||
// $serviceName_$appId_$releaseId is what we want. Once
|
|
||||||
// we have multi-app, we should show a dialog which
|
|
||||||
// allows the user to choose the correct container
|
|
||||||
|
|
||||||
const Docker = await import('dockerode');
|
|
||||||
const docker = new Docker({
|
|
||||||
host: opts.address,
|
|
||||||
port: 2375,
|
|
||||||
});
|
|
||||||
|
|
||||||
const regex = new RegExp(`(^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
|
|
||||||
const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
|
|
||||||
let allContainers: ContainerInfo[];
|
|
||||||
try {
|
|
||||||
allContainers = await docker.listContainers();
|
|
||||||
} catch (_e) {
|
|
||||||
throw new ExpectedError(stripIndent`
|
|
||||||
Could not access docker daemon on device ${opts.address}.
|
|
||||||
Please ensure the device is in local mode.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceNames: string[] = [];
|
|
||||||
const containers: Array<{ id: string; name: string }> = [];
|
|
||||||
for (const container of allContainers) {
|
|
||||||
for (const name of container.Names) {
|
|
||||||
if (regex.test(name)) {
|
|
||||||
containers.push({ id: container.Id, name });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const match = name.match(nameRegex);
|
|
||||||
if (match) {
|
|
||||||
serviceNames.push(match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (containers.length === 0) {
|
|
||||||
throw new ExpectedError(
|
|
||||||
`Could not find a service on device with name ${opts.service}. ${
|
|
||||||
serviceNames.length > 0
|
|
||||||
? `Available services:\n${reduce(
|
|
||||||
serviceNames,
|
|
||||||
(str, name) => `${str}\t${name}\n`,
|
|
||||||
'',
|
|
||||||
)}`
|
|
||||||
: ''
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (containers.length > 1) {
|
|
||||||
throw new ExpectedError(stripIndent`
|
|
||||||
Found more than one container matching service name "${opts.service}":
|
|
||||||
${containers.map((container) => container.name).join(', ')}
|
|
||||||
Use different service names to avoid ambiguity.
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerId = containers[0].id;
|
|
||||||
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
|
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
|
||||||
// stdin (fd=0) is not a tty when data is piped in, for example
|
// stdin (fd=0) is not a tty when data is piped in, for example
|
||||||
// echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1
|
// echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1
|
||||||
@ -103,17 +109,32 @@ export async function performLocalDeviceSSH(
|
|||||||
// https://assets.balena.io/newsletter/2020-01/pipe.png
|
// https://assets.balena.io/newsletter/2020-01/pipe.png
|
||||||
const isTTY = !!opts.forceTTY || (await import('tty')).isatty(0);
|
const isTTY = !!opts.forceTTY || (await import('tty')).isatty(0);
|
||||||
const ttyFlag = isTTY ? '-t' : '';
|
const ttyFlag = isTTY ? '-t' : '';
|
||||||
command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
|
cmd = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return spawnSshAndThrowOnError([
|
const { findBestUsernameForDevice, runRemoteCommand } = await import(
|
||||||
...(opts.verbose ? ['-vvv'] : []),
|
'../ssh'
|
||||||
'-t',
|
);
|
||||||
...['-p', opts.port ? opts.port.toString() : '22222'],
|
|
||||||
...['-o', 'LogLevel=ERROR'],
|
// Before we started using `findBestUsernameForDevice`, we tried the approach
|
||||||
...['-o', 'StrictHostKeyChecking=no'],
|
// of attempting ssh with the 'root' username first and, if that failed, then
|
||||||
...['-o', 'UserKnownHostsFile=/dev/null'],
|
// attempting ssh with a regular user (balenaCloud username). The problem with
|
||||||
`root@${opts.address}`,
|
// that approach was that it would print the following message to the console:
|
||||||
...(command ? [command] : []),
|
// "root@192.168.1.36: Permission denied (publickey)"
|
||||||
]);
|
// ... right before having success as a regular user, which looked broken or
|
||||||
|
// confusing from users' point of view. Capturing stderr to prevent that
|
||||||
|
// message from being printed is tricky because the messages printed to stderr
|
||||||
|
// may include the stderr output of remote commands that are of interest to
|
||||||
|
// the user. Workarounds based on delays (timing) are tricky too because a
|
||||||
|
// ssh session length may vary from a fraction of a second (non interactive)
|
||||||
|
// to hours or days.
|
||||||
|
const username = await findBestUsernameForDevice(opts.address);
|
||||||
|
|
||||||
|
await runRemoteCommand({
|
||||||
|
cmd,
|
||||||
|
hostname: opts.address,
|
||||||
|
port: Number(opts.port) || 'local',
|
||||||
|
username,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ import { ExpectedError, printErrorMessage } from '../errors';
|
|||||||
import { getVisuals, stripIndent, getCliForm } from './lazy';
|
import { getVisuals, stripIndent, getCliForm } from './lazy';
|
||||||
import Logger = require('./logger');
|
import Logger = require('./logger');
|
||||||
import { confirm } from './patterns';
|
import { confirm } from './patterns';
|
||||||
import { exec, execBuffered, getDeviceOsRelease } from './ssh';
|
import { getLocalDeviceCmdStdout, getDeviceOsRelease } from './ssh';
|
||||||
|
|
||||||
const MIN_BALENAOS_VERSION = 'v2.14.0';
|
const MIN_BALENAOS_VERSION = 'v2.14.0';
|
||||||
|
|
||||||
@ -88,20 +88,25 @@ async function execCommand(
|
|||||||
cmd: string,
|
cmd: string,
|
||||||
msg: string,
|
msg: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const through = await import('through2');
|
const { Writable } = await import('stream');
|
||||||
const visuals = getVisuals();
|
const visuals = getVisuals();
|
||||||
|
|
||||||
const spinner = new visuals.Spinner(`[${deviceIp}] Connecting...`);
|
const spinner = new visuals.Spinner(`[${deviceIp}] Connecting...`);
|
||||||
const innerSpinner = spinner.spinner;
|
const innerSpinner = spinner.spinner;
|
||||||
|
|
||||||
const stream = through(function (data, _enc, cb) {
|
const stream = new Writable({
|
||||||
innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`);
|
write(_chunk: Buffer, _enc, callback) {
|
||||||
cb(null, data);
|
innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
spinner.start();
|
spinner.start();
|
||||||
await exec(deviceIp, cmd, stream);
|
try {
|
||||||
spinner.stop();
|
await getLocalDeviceCmdStdout(deviceIp, cmd, stream);
|
||||||
|
} finally {
|
||||||
|
spinner.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configure(deviceIp: string, config: any): Promise<void> {
|
async function configure(deviceIp: string, config: any): Promise<void> {
|
||||||
@ -121,7 +126,7 @@ async function deconfigure(deviceIp: string): Promise<void> {
|
|||||||
async function assertDeviceIsCompatible(deviceIp: string): Promise<void> {
|
async function assertDeviceIsCompatible(deviceIp: string): Promise<void> {
|
||||||
const cmd = 'os-config --version';
|
const cmd = 'os-config --version';
|
||||||
try {
|
try {
|
||||||
await execBuffered(deviceIp, cmd);
|
await getLocalDeviceCmdStdout(deviceIp, cmd);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ExpectedError) {
|
if (err instanceof ExpectedError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
372
lib/utils/ssh.ts
372
lib/utils/ssh.ts
@ -16,147 +16,309 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn, StdioOptions } from 'child_process';
|
import { spawn, StdioOptions } from 'child_process';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import { TypedError } from 'typed-error';
|
|
||||||
|
|
||||||
import { ExpectedError } from '../errors';
|
import { ExpectedError } from '../errors';
|
||||||
|
|
||||||
export class ExecError extends TypedError {
|
export class SshPermissionDeniedError extends ExpectedError {}
|
||||||
public cmd: string;
|
|
||||||
public exitCode: number;
|
|
||||||
|
|
||||||
constructor(cmd: string, exitCode: number) {
|
export class RemoteCommandError extends ExpectedError {
|
||||||
super(`Command '${cmd}' failed with error: ${exitCode}`);
|
cmd: string;
|
||||||
|
exitCode?: number;
|
||||||
|
exitSignal?: NodeJS.Signals;
|
||||||
|
|
||||||
|
constructor(cmd: string, exitCode?: number, exitSignal?: NodeJS.Signals) {
|
||||||
|
super(sshErrorMessage(cmd, exitSignal, exitCode));
|
||||||
this.cmd = cmd;
|
this.cmd = cmd;
|
||||||
this.exitCode = exitCode;
|
this.exitCode = exitCode;
|
||||||
|
this.exitSignal = exitSignal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exec(
|
export interface SshRemoteCommandOpts {
|
||||||
deviceIp: string,
|
cmd?: string;
|
||||||
cmd: string,
|
hostname: string;
|
||||||
stdout?: NodeJS.WritableStream,
|
ignoreStdin?: boolean;
|
||||||
): Promise<void> {
|
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 { which } = await import('./which');
|
||||||
const program = await which('ssh');
|
const program = await which('ssh');
|
||||||
const args = [
|
const args = sshArgsForRemoteCommand({
|
||||||
'-n',
|
|
||||||
'-t',
|
|
||||||
'-p',
|
|
||||||
'22222',
|
|
||||||
'-o',
|
|
||||||
'LogLevel=ERROR',
|
|
||||||
'-o',
|
|
||||||
'StrictHostKeyChecking=no',
|
|
||||||
'-o',
|
|
||||||
'UserKnownHostsFile=/dev/null',
|
|
||||||
`root@${deviceIp}`,
|
|
||||||
cmd,
|
cmd,
|
||||||
];
|
hostname,
|
||||||
|
ignoreStdin,
|
||||||
|
port,
|
||||||
|
proxyCommand,
|
||||||
|
username,
|
||||||
|
verbose,
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
const logger = (await import('./logger')).getLogger();
|
const logger = (await import('./logger')).getLogger();
|
||||||
logger.logDebug(`Executing [${program},${args}]`);
|
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 = [
|
const stdio: StdioOptions = [
|
||||||
'inherit',
|
typeof stdin === 'string' ? stdin : 'pipe',
|
||||||
stdout ? 'pipe' : 'inherit',
|
typeof stdout === 'string' ? stdout : 'pipe',
|
||||||
'inherit',
|
typeof stderr === 'string' ? stderr : 'pipe',
|
||||||
];
|
];
|
||||||
|
let exitCode: number | undefined;
|
||||||
|
let exitSignal: NodeJS.Signals | undefined;
|
||||||
|
try {
|
||||||
|
[exitCode, exitSignal] = await new Promise<[number, NodeJS.Signals]>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const ps = spawn(program, args, { stdio })
|
||||||
|
.on('error', reject)
|
||||||
|
.on('close', (code, signal) => resolve([code, signal]));
|
||||||
|
|
||||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
if (ps.stdin && stdin && typeof stdin !== 'string') {
|
||||||
const ps = spawn(program, args, { stdio })
|
stdin.pipe(ps.stdin);
|
||||||
.on('error', reject)
|
}
|
||||||
.on('close', resolve);
|
if (ps.stdout && stdout && typeof stdout !== 'string') {
|
||||||
|
ps.stdout.pipe(stdout);
|
||||||
if (stdout && ps.stdout) {
|
}
|
||||||
ps.stdout.pipe(stdout);
|
if (ps.stderr && stderr && typeof stderr !== 'string') {
|
||||||
}
|
ps.stderr.pipe(stderr);
|
||||||
});
|
}
|
||||||
if (exitCode !== 0) {
|
},
|
||||||
throw new ExecError(cmd, exitCode);
|
);
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function execBuffered(
|
/**
|
||||||
deviceIp: string,
|
* Execute the given command on a local balenaOS device over ssh.
|
||||||
cmd: string,
|
* Capture stdout and/or stderr to Buffers and return them.
|
||||||
enc?: string,
|
*
|
||||||
): Promise<string> {
|
* @param deviceIp IP address of the local device
|
||||||
const through = await import('through2');
|
* @param cmd Shell command to execute on the device
|
||||||
const buffer: string[] = [];
|
* @param opts Options
|
||||||
await exec(
|
* @param opts.username SSH username for authorization. With balenaOS 2.44.0 or
|
||||||
deviceIp,
|
* 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,
|
cmd,
|
||||||
through(function (data, _enc, cb) {
|
hostname,
|
||||||
buffer.push(data.toString(enc));
|
port,
|
||||||
cb();
|
proxyCommand,
|
||||||
}),
|
stdin,
|
||||||
);
|
stdout: stdout === 'capture' ? stdoutStream : stdout,
|
||||||
return buffer.join('');
|
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> {
|
||||||
|
return (
|
||||||
|
await getRemoteCommandOutput({
|
||||||
|
cmd,
|
||||||
|
hostname,
|
||||||
|
port: 'local',
|
||||||
|
stdout,
|
||||||
|
stderr: 'inherit',
|
||||||
|
username: await findBestUsernameForDevice(hostname),
|
||||||
|
})
|
||||||
|
).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 = 'local') => {
|
||||||
|
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 = 'local'): Promise<string> => {
|
||||||
|
let username: string | undefined;
|
||||||
|
if (await isRootUserGood(hostname, port)) {
|
||||||
|
username = 'root';
|
||||||
|
} else {
|
||||||
|
const { getCachedUsername } = await import('./bootstrap');
|
||||||
|
username = (await getCachedUsername())?.username;
|
||||||
|
}
|
||||||
|
return username || 'root';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a device's balenaOS release by executing 'cat /etc/os-release'
|
* Return a device's balenaOS release by executing 'cat /etc/os-release'
|
||||||
* over ssh to the given deviceIp address. The result is cached with
|
* over ssh to the given deviceIp address. The result is cached with
|
||||||
* lodash's memoize.
|
* lodash's memoize.
|
||||||
*/
|
*/
|
||||||
export const getDeviceOsRelease = _.memoize(async (deviceIp: string) =>
|
export const getDeviceOsRelease = _.memoize(async (hostname: string) =>
|
||||||
execBuffered(deviceIp, 'cat /etc/os-release'),
|
(await getLocalDeviceCmdStdout(hostname, 'cat /etc/os-release')).toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: consolidate the various forms of executing ssh child processes
|
function sshErrorMessage(cmd: string, exitSignal?: string, exitCode?: number) {
|
||||||
// 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('./which');
|
|
||||||
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[] = [];
|
const msg: string[] = [];
|
||||||
|
cmd = cmd ? `Remote command "${cmd}"` : 'Process';
|
||||||
if (exitSignal) {
|
if (exitSignal) {
|
||||||
msg.push(`Warning: ssh process was terminated with signal "${exitSignal}"`);
|
msg.push(`SSH: ${cmd} terminated with signal "${exitSignal}"`);
|
||||||
} else {
|
} else {
|
||||||
msg.push(`Warning: ssh process exited with non-zero code "${exitCode}"`);
|
msg.push(`SSH: ${cmd} exited with non-zero status code "${exitCode}"`);
|
||||||
switch (exitCode) {
|
switch (exitCode) {
|
||||||
case 255:
|
case 255:
|
||||||
msg.push(`
|
msg.push(`
|
||||||
|
@ -95,52 +95,3 @@ export async function which(
|
|||||||
}
|
}
|
||||||
return programPath;
|
return programPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Call which(programName) and spawn() with the given arguments.
|
|
||||||
*
|
|
||||||
* If returnExitCodeOrSignal is true, the returned promise will resolve to
|
|
||||||
* an array [code, signal] with the child process exit code number or exit
|
|
||||||
* signal string respectively (as provided by the spawn close event).
|
|
||||||
*
|
|
||||||
* If returnExitCodeOrSignal is false, the returned promise will reject with
|
|
||||||
* a custom error if the child process returns a non-zero exit code or a
|
|
||||||
* non-empty signal string (as reported by the spawn close event).
|
|
||||||
*
|
|
||||||
* In either case and if spawn itself emits an error event or fails synchronously,
|
|
||||||
* the returned promise will reject with a custom error that includes the error
|
|
||||||
* message of spawn's error.
|
|
||||||
*/
|
|
||||||
export async function whichSpawn(
|
|
||||||
programName: string,
|
|
||||||
args: string[],
|
|
||||||
options: import('child_process').SpawnOptions = { stdio: 'inherit' },
|
|
||||||
returnExitCodeOrSignal = false,
|
|
||||||
): Promise<[number | undefined, string | undefined]> {
|
|
||||||
const { spawn } = await import('child_process');
|
|
||||||
const program = await which(programName);
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error(`[debug] [${program}, ${args.join(', ')}]`);
|
|
||||||
}
|
|
||||||
let error: Error | undefined;
|
|
||||||
let exitCode: number | undefined;
|
|
||||||
let exitSignal: string | undefined;
|
|
||||||
try {
|
|
||||||
[exitCode, exitSignal] = await new Promise((resolve, reject) => {
|
|
||||||
spawn(program, args, options)
|
|
||||||
.on('error', reject)
|
|
||||||
.on('close', (code, signal) => resolve([code, signal]));
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
error = err;
|
|
||||||
}
|
|
||||||
if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
|
|
||||||
const msg = [
|
|
||||||
`${programName} failed with exit code=${exitCode} signal=${exitSignal}:`,
|
|
||||||
`[${program}, ${args.join(', ')}]`,
|
|
||||||
...(error ? [`${error}`] : []),
|
|
||||||
];
|
|
||||||
throw new Error(msg.join('\n'));
|
|
||||||
}
|
|
||||||
return [exitCode, exitSignal];
|
|
||||||
}
|
|
||||||
|
@ -32,33 +32,51 @@ describe('balena ssh', function () {
|
|||||||
let hasSshExecutable = false;
|
let hasSshExecutable = false;
|
||||||
let mockedExitCode = 0;
|
let mockedExitCode = 0;
|
||||||
|
|
||||||
|
async function mockSpawn({ revert = false } = {}) {
|
||||||
|
if (revert) {
|
||||||
|
mock.stopAll();
|
||||||
|
mock.reRequire('../../build/utils/ssh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { EventEmitter } = await import('stream');
|
||||||
|
const childProcessMod = await import('child_process');
|
||||||
|
const originalSpawn = childProcessMod.spawn;
|
||||||
|
mock('child_process', {
|
||||||
|
...childProcessMod,
|
||||||
|
spawn: (program: string, ...args: any[]) => {
|
||||||
|
if (program.includes('ssh')) {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
setTimeout(() => emitter.emit('close', mockedExitCode), 1);
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
return originalSpawn(program, ...args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.beforeAll(async function () {
|
this.beforeAll(async function () {
|
||||||
hasSshExecutable = await checkSsh();
|
hasSshExecutable = await checkSsh();
|
||||||
if (hasSshExecutable) {
|
if (!hasSshExecutable) {
|
||||||
[sshServer, sshServerPort] = await startMockSshServer();
|
this.skip();
|
||||||
}
|
}
|
||||||
const modPath = '../../build/utils/which';
|
[sshServer, sshServerPort] = await startMockSshServer();
|
||||||
const mod = await import(modPath);
|
await mockSpawn();
|
||||||
mock(modPath, {
|
|
||||||
...mod,
|
|
||||||
whichSpawn: async () => [mockedExitCode, undefined],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.afterAll(function () {
|
this.afterAll(async function () {
|
||||||
if (sshServer) {
|
if (sshServer) {
|
||||||
sshServer.close();
|
sshServer.close();
|
||||||
sshServer = undefined;
|
sshServer = undefined;
|
||||||
}
|
}
|
||||||
mock.stopAll();
|
await mockSpawn({ revert: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(function () {
|
||||||
api = new BalenaAPIMock();
|
api = new BalenaAPIMock();
|
||||||
api.expectGetMixpanel({ optional: true });
|
api.expectGetMixpanel({ optional: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(function () {
|
||||||
// Check all expected api calls have been made and clean up.
|
// Check all expected api calls have been made and clean up.
|
||||||
api.done();
|
api.done();
|
||||||
});
|
});
|
||||||
@ -87,7 +105,7 @@ describe('balena ssh', function () {
|
|||||||
async () => {
|
async () => {
|
||||||
const deviceUUID = 'abc1234';
|
const deviceUUID = 'abc1234';
|
||||||
const expectedErrLines = [
|
const expectedErrLines = [
|
||||||
'Warning: ssh process exited with non-zero code "255"',
|
'SSH: Remote command "host abc1234" exited with non-zero status code "255"',
|
||||||
];
|
];
|
||||||
api.expectGetWhoAmI({ optional: true, persist: true });
|
api.expectGetWhoAmI({ optional: true, persist: true });
|
||||||
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
|
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
|
||||||
@ -99,21 +117,6 @@ describe('balena ssh', function () {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('should produce the expected error message (real ssh, device IP address)', async function () {
|
|
||||||
if (!hasSshExecutable) {
|
|
||||||
this.skip();
|
|
||||||
}
|
|
||||||
mock.stop('../../build/utils/helpers');
|
|
||||||
const expectedErrLines = [
|
|
||||||
'Warning: ssh process exited with non-zero code "255"',
|
|
||||||
];
|
|
||||||
const { err, out } = await runCommand(
|
|
||||||
`ssh 127.0.0.1 -p ${sshServerPort} --noproxy`,
|
|
||||||
);
|
|
||||||
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
|
|
||||||
expect(out).to.be.empty;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail if device not online (mocked, device UUID)', async () => {
|
it('should fail if device not online (mocked, device UUID)', async () => {
|
||||||
const deviceUUID = 'abc1234';
|
const deviceUUID = 'abc1234';
|
||||||
const expectedErrLines = ['Device with UUID abc1234 is offline'];
|
const expectedErrLines = ['Device with UUID abc1234 is offline'];
|
||||||
@ -126,6 +129,18 @@ describe('balena ssh', function () {
|
|||||||
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
|
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
|
||||||
expect(out).to.be.empty;
|
expect(out).to.be.empty;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should produce the expected error message (real ssh, device IP address)', async function () {
|
||||||
|
await mockSpawn({ revert: true });
|
||||||
|
const expectedErrLines = [
|
||||||
|
'SSH: Process exited with non-zero status code "255"',
|
||||||
|
];
|
||||||
|
const { err, out } = await runCommand(
|
||||||
|
`ssh 127.0.0.1 -p ${sshServerPort} --noproxy`,
|
||||||
|
);
|
||||||
|
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
|
||||||
|
expect(out).to.be.empty;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Check whether the 'ssh' tool (executable) exists in the PATH */
|
/** Check whether the 'ssh' tool (executable) exists in the PATH */
|
||||||
|
Loading…
Reference in New Issue
Block a user