mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-06-23 01:58:51 +00:00
Convert command ssh to oclif.
Change-type: patch Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
@ -107,7 +107,7 @@ const capitanoDoc = {
|
|||||||
title: 'Network',
|
title: 'Network',
|
||||||
files: [
|
files: [
|
||||||
'build/actions-oclif/scan.js',
|
'build/actions-oclif/scan.js',
|
||||||
'build/actions/ssh.js',
|
'build/actions-oclif/ssh.js',
|
||||||
'build/actions/tunnel.js',
|
'build/actions/tunnel.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -213,7 +213,7 @@ Users are encouraged to regularly update the balena CLI to the latest version.
|
|||||||
- Network
|
- Network
|
||||||
|
|
||||||
- [scan](#scan)
|
- [scan](#scan)
|
||||||
- [ssh <applicationOrDevice> [serviceName]](#ssh-applicationordevice-servicename)
|
- [ssh <applicationordevice> [servicename]](#ssh-applicationordevice-servicename)
|
||||||
- [tunnel <deviceOrApplication>](#tunnel-deviceorapplication)
|
- [tunnel <deviceOrApplication>](#tunnel-deviceorapplication)
|
||||||
|
|
||||||
- Notes
|
- Notes
|
||||||
@ -1368,9 +1368,8 @@ scan timeout in seconds
|
|||||||
|
|
||||||
## ssh <applicationOrDevice> [serviceName]
|
## ssh <applicationOrDevice> [serviceName]
|
||||||
|
|
||||||
This command can be used to start a shell on a local or remote device.
|
Start a shell on a local or remote device. If a service name is not provided,
|
||||||
|
a shell will be opened on the host OS.
|
||||||
If a service name is not provided, a shell will be opened on the host OS.
|
|
||||||
|
|
||||||
If an application name is provided, an interactive menu will be presented
|
If an application name is provided, an interactive menu will be presented
|
||||||
for the selection of an online device. A shell will then be opened for the
|
for the selection of an online device. A shell will then be opened for the
|
||||||
@ -1382,33 +1381,49 @@ is initiated directly to balenaOS on port `22222` via an
|
|||||||
openssh-compatible client. Otherwise, any connection initiated remotely
|
openssh-compatible client. Otherwise, any connection initiated remotely
|
||||||
traverses the balenaCloud VPN.
|
traverses the balenaCloud VPN.
|
||||||
|
|
||||||
Examples:
|
Commands may be piped to the standard input for remote execution (see examples).
|
||||||
balena ssh MyApp
|
Note however that remote command execution on service containers (as opposed to
|
||||||
|
the host OS) is not currently possible when a device UUID is used (instead of
|
||||||
|
an IP address) because of a balenaCloud backend limitation.
|
||||||
|
|
||||||
balena ssh f49cefd
|
Note: `balena ssh` requires an openssh-compatible client to be correctly
|
||||||
balena ssh f49cefd my-service
|
|
||||||
balena ssh f49cefd --port <port>
|
|
||||||
|
|
||||||
balena ssh 192.168.0.1 --verbose
|
|
||||||
balena ssh f49cefd.local my-service
|
|
||||||
|
|
||||||
Warning: `balena ssh` requires an openssh-compatible client to be correctly
|
|
||||||
installed in your shell environment. For more information (including Windows
|
installed in your shell environment. For more information (including Windows
|
||||||
support) please check:
|
support) please check:
|
||||||
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies
|
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies,
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
$ balena ssh MyApp
|
||||||
|
$ balena ssh f49cefd
|
||||||
|
$ balena ssh f49cefd my-service
|
||||||
|
$ balena ssh f49cefd --port <port>
|
||||||
|
$ balena ssh 192.168.0.1 --verbose
|
||||||
|
$ balena ssh f49cefd.local my-service
|
||||||
|
$ echo "uptime; exit;" | balena ssh f49cefd
|
||||||
|
$ echo "uptime; exit;" | balena ssh 192.168.0.1 myService
|
||||||
|
|
||||||
|
### Arguments
|
||||||
|
|
||||||
|
#### APPLICATIONORDEVICE
|
||||||
|
|
||||||
|
application name, device uuid, or address of local device
|
||||||
|
|
||||||
|
#### SERVICENAME
|
||||||
|
|
||||||
|
service name, if connecting to a container
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
#### --port, -p <port>
|
#### -p, --port PORT
|
||||||
|
|
||||||
SSH server port number (default 22222) if the target is an IP address or .local
|
SSH server port number (default 22222) if the target is an IP address or .local
|
||||||
hostname. Otherwise, port number for the balenaCloud gateway (default 22).
|
hostname. Otherwise, port number for the balenaCloud gateway (default 22).
|
||||||
|
|
||||||
#### --tty, -t
|
#### -t, --tty
|
||||||
|
|
||||||
Force pseudo-terminal allocation (bypass TTY autodetection for stdin)
|
Force pseudo-terminal allocation (bypass TTY autodetection for stdin)
|
||||||
|
|
||||||
#### --verbose, -v
|
#### -v, --verbose
|
||||||
|
|
||||||
Increase verbosity
|
Increase verbosity
|
||||||
|
|
||||||
|
393
lib/actions-oclif/ssh.ts
Normal file
393
lib/actions-oclif/ssh.ts
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2016-2020 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 { flags } from '@oclif/command';
|
||||||
|
import Command from '../command';
|
||||||
|
import * as cf from '../utils/common-flags';
|
||||||
|
import { getBalenaSdk, stripIndent } from '../utils/lazy';
|
||||||
|
import {
|
||||||
|
parseAsInteger,
|
||||||
|
validateDotLocalUrl,
|
||||||
|
validateIPAddress,
|
||||||
|
} from '../utils/validation';
|
||||||
|
import * as BalenaSdk from 'balena-sdk';
|
||||||
|
|
||||||
|
interface FlagsDef {
|
||||||
|
port?: number;
|
||||||
|
tty: boolean;
|
||||||
|
verbose: boolean;
|
||||||
|
noproxy: boolean;
|
||||||
|
help: void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArgsDef {
|
||||||
|
applicationOrDevice: string;
|
||||||
|
serviceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class NoteCmd extends Command {
|
||||||
|
public static description = stripIndent`
|
||||||
|
SSH into the host or application container of a device.
|
||||||
|
|
||||||
|
Start a shell on a local or remote device. If a service name is not provided,
|
||||||
|
a shell will be opened on the host OS.
|
||||||
|
|
||||||
|
If an application name is provided, an interactive menu will be presented
|
||||||
|
for the selection of an online device. A shell will then be opened for the
|
||||||
|
host OS or service container of the chosen device.
|
||||||
|
|
||||||
|
For local devices, the IP address and .local domain name are supported.
|
||||||
|
If the device is referenced by IP or \`.local\` address, the connection
|
||||||
|
is initiated directly to balenaOS on port \`22222\` via an
|
||||||
|
openssh-compatible client. Otherwise, any connection initiated remotely
|
||||||
|
traverses the balenaCloud VPN.
|
||||||
|
|
||||||
|
Commands may be piped to the standard input for remote execution (see examples).
|
||||||
|
Note however that remote command execution on service containers (as opposed to
|
||||||
|
the host OS) is not currently possible when a device UUID is used (instead of
|
||||||
|
an IP address) because of a balenaCloud backend limitation.
|
||||||
|
|
||||||
|
Note: \`balena ssh\` requires an openssh-compatible client to be correctly
|
||||||
|
installed in your shell environment. For more information (including Windows
|
||||||
|
support) please check:
|
||||||
|
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies,
|
||||||
|
`;
|
||||||
|
|
||||||
|
public static examples = [
|
||||||
|
'$ balena ssh MyApp',
|
||||||
|
'$ balena ssh f49cefd',
|
||||||
|
'$ balena ssh f49cefd my-service',
|
||||||
|
'$ balena ssh f49cefd --port <port>',
|
||||||
|
'$ balena ssh 192.168.0.1 --verbose',
|
||||||
|
'$ balena ssh f49cefd.local my-service',
|
||||||
|
'$ echo "uptime; exit;" | balena ssh f49cefd',
|
||||||
|
'$ echo "uptime; exit;" | balena ssh 192.168.0.1 myService',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static args = [
|
||||||
|
{
|
||||||
|
name: 'applicationOrDevice',
|
||||||
|
description: 'application name, device uuid, or address of local device',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'serviceName',
|
||||||
|
description: 'service name, if connecting to a container',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
public static usage = 'ssh <applicationOrDevice> [serviceName]';
|
||||||
|
|
||||||
|
public static flags: flags.Input<FlagsDef> = {
|
||||||
|
port: flags.integer({
|
||||||
|
description: stripIndent`
|
||||||
|
SSH server port number (default 22222) if the target is an IP address or .local
|
||||||
|
hostname. Otherwise, port number for the balenaCloud gateway (default 22).`,
|
||||||
|
char: 'p',
|
||||||
|
parse: (p) => parseAsInteger(p, 'port'),
|
||||||
|
}),
|
||||||
|
tty: flags.boolean({
|
||||||
|
description:
|
||||||
|
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
|
||||||
|
char: 't',
|
||||||
|
}),
|
||||||
|
verbose: flags.boolean({
|
||||||
|
description: 'Increase verbosity',
|
||||||
|
char: 'v',
|
||||||
|
}),
|
||||||
|
noproxy: flags.boolean({
|
||||||
|
description: 'Bypass global proxy configuration for the ssh connection',
|
||||||
|
}),
|
||||||
|
help: cf.help,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static primary = true;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||||
|
NoteCmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ExpectedError } = await import('../errors');
|
||||||
|
const { getProxyConfig, which } = await import('../utils/helpers');
|
||||||
|
const { checkLoggedIn, getOnlineTargetUuid } = await import(
|
||||||
|
'../utils/patterns'
|
||||||
|
);
|
||||||
|
const { spawnSshAndExitOnError } = await import('../utils/ssh');
|
||||||
|
const sdk = getBalenaSdk();
|
||||||
|
|
||||||
|
const proxyConfig = getProxyConfig();
|
||||||
|
const useProxy = !!proxyConfig && !options.noproxy;
|
||||||
|
|
||||||
|
// if we're doing a direct SSH connection locally...
|
||||||
|
if (
|
||||||
|
validateDotLocalUrl(params.applicationOrDevice) ||
|
||||||
|
validateIPAddress(params.applicationOrDevice)
|
||||||
|
) {
|
||||||
|
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
|
||||||
|
return await performLocalDeviceSSH({
|
||||||
|
address: params.applicationOrDevice,
|
||||||
|
port: options.port,
|
||||||
|
forceTTY: options.tty,
|
||||||
|
verbose: options.verbose,
|
||||||
|
service: params.serviceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// this will be a tunnelled SSH connection...
|
||||||
|
await checkLoggedIn();
|
||||||
|
const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice);
|
||||||
|
let version: string | undefined;
|
||||||
|
let id: number | undefined;
|
||||||
|
|
||||||
|
const device = await sdk.models.device.get(uuid, {
|
||||||
|
$select: ['id', 'supervisor_version', 'is_online'],
|
||||||
|
});
|
||||||
|
id = device.id;
|
||||||
|
version = device.supervisor_version;
|
||||||
|
|
||||||
|
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
|
||||||
|
useProxy ? which('proxytunnel', false) : undefined,
|
||||||
|
sdk.auth.whoami(),
|
||||||
|
// note that `proxyUrl` refers to the balenaCloud "resin-proxy"
|
||||||
|
// service, currently "balena-devices.com", rather than some
|
||||||
|
// local proxy server URL
|
||||||
|
sdk.settings.get('proxyUrl'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getSshProxyCommand = () => {
|
||||||
|
if (!proxyConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!whichProxytunnel) {
|
||||||
|
console.warn(stripIndent`
|
||||||
|
Proxy is enabled but the \`proxytunnel\` binary cannot be found.
|
||||||
|
Please install it if you want to route the \`balena ssh\` requests through the proxy.
|
||||||
|
Alternatively you can pass \`--noproxy\` param to the \`balena ssh\` command to ignore the proxy config
|
||||||
|
for the \`ssh\` requests.
|
||||||
|
|
||||||
|
Attempting the unproxied request for now.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = proxyConfig;
|
||||||
|
if (p.username && p.password) {
|
||||||
|
// proxytunnel understands these variables for proxy authentication.
|
||||||
|
// Setting the variables instead of command-line options avoids the
|
||||||
|
// need for shell-specific escaping of special characters like '$'.
|
||||||
|
process.env.PROXYUSER = p.username;
|
||||||
|
process.env.PROXYPASS = p.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'proxytunnel',
|
||||||
|
`--proxy=${p.host}:${p.port}`,
|
||||||
|
// ssh replaces these %h:%p variables in the ProxyCommand option
|
||||||
|
// https://linux.die.net/man/5/ssh_config
|
||||||
|
'--dest=%h:%p',
|
||||||
|
...(options.verbose ? ['--verbose'] : []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
|
||||||
|
|
||||||
|
if (username == null) {
|
||||||
|
throw new ExpectedError(
|
||||||
|
`Opening an SSH connection to a remote device requires you to be logged in.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, we have a long uuid with a device
|
||||||
|
// that we know exists and is accessible
|
||||||
|
let containerId: string | undefined;
|
||||||
|
if (params.serviceName != null) {
|
||||||
|
containerId = await this.getContainerId(
|
||||||
|
sdk,
|
||||||
|
uuid,
|
||||||
|
params.serviceName,
|
||||||
|
{
|
||||||
|
port: options.port,
|
||||||
|
proxyCommand,
|
||||||
|
proxyUrl: proxyUrl || '',
|
||||||
|
username: username!,
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessCommand: string;
|
||||||
|
if (containerId != null) {
|
||||||
|
accessCommand = `enter ${uuid} ${containerId}`;
|
||||||
|
} else {
|
||||||
|
accessCommand = `host ${uuid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = this.generateVpnSshCommand({
|
||||||
|
uuid,
|
||||||
|
command: accessCommand,
|
||||||
|
verbose: options.verbose,
|
||||||
|
port: options.port,
|
||||||
|
proxyCommand,
|
||||||
|
proxyUrl: proxyUrl || '',
|
||||||
|
username: username!,
|
||||||
|
});
|
||||||
|
|
||||||
|
return spawnSshAndExitOnError(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContainerId(
|
||||||
|
sdk: BalenaSdk.BalenaSDK,
|
||||||
|
uuid: string,
|
||||||
|
serviceName: string,
|
||||||
|
sshOpts: {
|
||||||
|
port?: number;
|
||||||
|
proxyCommand?: string[];
|
||||||
|
proxyUrl: string;
|
||||||
|
username: string;
|
||||||
|
},
|
||||||
|
version?: string,
|
||||||
|
id?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const semver = await import('balena-semver');
|
||||||
|
|
||||||
|
if (version == null || id == null) {
|
||||||
|
const device = await sdk.models.device.get(uuid, {
|
||||||
|
$select: ['id', 'supervisor_version'],
|
||||||
|
});
|
||||||
|
version = device.supervisor_version;
|
||||||
|
id = device.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let containerId: string | undefined;
|
||||||
|
if (semver.gte(version, '8.6.0')) {
|
||||||
|
const apiUrl = await sdk.settings.get('apiUrl');
|
||||||
|
// TODO: Move this into the SDKs device model
|
||||||
|
const request = await sdk.request.send({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/supervisor/v2/containerId',
|
||||||
|
baseUrl: apiUrl,
|
||||||
|
body: {
|
||||||
|
method: 'GET',
|
||||||
|
deviceId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (request.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = request.body;
|
||||||
|
if (body.status !== 'success') {
|
||||||
|
throw new Error(
|
||||||
|
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
containerId = body.services[serviceName];
|
||||||
|
} else {
|
||||||
|
console.error(stripIndent`
|
||||||
|
Using legacy method to detect container ID. This will be slow.
|
||||||
|
To speed up this process, please update your device to an OS
|
||||||
|
which has a supervisor version of at least v8.6.0.
|
||||||
|
`);
|
||||||
|
// We need to execute a balena ps command on the device,
|
||||||
|
// and parse the output, looking for a specific
|
||||||
|
// container
|
||||||
|
const childProcess = await import('child_process');
|
||||||
|
const escapeRegex = await import('lodash/escapeRegExp');
|
||||||
|
const { which } = await import('../utils/helpers');
|
||||||
|
const { deviceContainerEngineBinary } = await import(
|
||||||
|
'../utils/device/ssh'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sshBinary = await which('ssh');
|
||||||
|
const sshArgs = this.generateVpnSshCommand({
|
||||||
|
uuid,
|
||||||
|
verbose: false,
|
||||||
|
port: sshOpts.port,
|
||||||
|
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
|
||||||
|
proxyCommand: sshOpts.proxyCommand,
|
||||||
|
proxyUrl: sshOpts.proxyUrl,
|
||||||
|
username: sshOpts.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 regex = new RegExp(`\\/?${escapeRegex(serviceName)}_\\d+_\\d+`);
|
||||||
|
for (const container of lines) {
|
||||||
|
const [cId, name] = container.split(' ');
|
||||||
|
if (regex.test(name)) {
|
||||||
|
containerId = cId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerId == null) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find a service ${serviceName} on device ${uuid}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -55,7 +55,11 @@ function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
|
|||||||
// same effect as the 's' regex flag which is only supported by Node 9+
|
// same effect as the 's' regex flag which is only supported by Node 9+
|
||||||
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
|
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
|
||||||
if (matches && matches.length > 1) {
|
if (matches && matches.length > 1) {
|
||||||
description = _.lowerFirst(_.trimEnd(matches[1], '.'));
|
description = _.trimEnd(matches[1], '.');
|
||||||
|
// Only do .lowerFirst() if the second char is not uppercase (e.g. for 'SSH');
|
||||||
|
if (description[1] !== description[1]?.toUpperCase()) {
|
||||||
|
description = _.lowerFirst(description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [usage, description];
|
return [usage, description];
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ export * as local from './local';
|
|||||||
export * as logs from './logs';
|
export * as logs from './logs';
|
||||||
export * as os from './os';
|
export * as os from './os';
|
||||||
export * as push from './push';
|
export * as push from './push';
|
||||||
export * as ssh from './ssh';
|
|
||||||
export * as tunnel from './tunnel';
|
export * as tunnel from './tunnel';
|
||||||
export * as util from './util';
|
export * as util from './util';
|
||||||
|
|
||||||
|
@ -1,368 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2016-2020 Balena
|
|
||||||
|
|
||||||
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 * as BalenaSdk from 'balena-sdk';
|
|
||||||
import type { CommandDefinition } from 'capitano';
|
|
||||||
|
|
||||||
import { getBalenaSdk, stripIndent } from '../utils/lazy';
|
|
||||||
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
|
|
||||||
|
|
||||||
async function getContainerId(
|
|
||||||
sdk: BalenaSdk.BalenaSDK,
|
|
||||||
uuid: string,
|
|
||||||
serviceName: string,
|
|
||||||
sshOpts: {
|
|
||||||
port?: number;
|
|
||||||
proxyCommand?: string[];
|
|
||||||
proxyUrl: string;
|
|
||||||
username: string;
|
|
||||||
},
|
|
||||||
version?: string,
|
|
||||||
id?: number,
|
|
||||||
): Promise<string> {
|
|
||||||
const semver = await import('balena-semver');
|
|
||||||
|
|
||||||
if (version == null || id == null) {
|
|
||||||
const device = await sdk.models.device.get(uuid, {
|
|
||||||
$select: ['id', 'supervisor_version'],
|
|
||||||
});
|
|
||||||
version = device.supervisor_version;
|
|
||||||
id = device.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let containerId: string | undefined;
|
|
||||||
if (semver.gte(version, '8.6.0')) {
|
|
||||||
const apiUrl = await sdk.settings.get('apiUrl');
|
|
||||||
// TODO: Move this into the SDKs device model
|
|
||||||
const request = await sdk.request.send({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/supervisor/v2/containerId',
|
|
||||||
baseUrl: apiUrl,
|
|
||||||
body: {
|
|
||||||
method: 'GET',
|
|
||||||
deviceId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (request.status !== 200) {
|
|
||||||
throw new Error(
|
|
||||||
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = request.body;
|
|
||||||
if (body.status !== 'success') {
|
|
||||||
throw new Error(
|
|
||||||
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
containerId = body.services[serviceName];
|
|
||||||
} else {
|
|
||||||
console.error(stripIndent`
|
|
||||||
Using legacy method to detect container ID. This will be slow.
|
|
||||||
To speed up this process, please update your device to an OS
|
|
||||||
which has a supervisor version of at least v8.6.0.
|
|
||||||
`);
|
|
||||||
// We need to execute a balena ps command on the device,
|
|
||||||
// and parse the output, looking for a specific
|
|
||||||
// container
|
|
||||||
const childProcess = await import('child_process');
|
|
||||||
const escapeRegex = await import('lodash/escapeRegExp');
|
|
||||||
const { which } = await import('../utils/helpers');
|
|
||||||
const { deviceContainerEngineBinary } = await import('../utils/device/ssh');
|
|
||||||
|
|
||||||
const sshBinary = await which('ssh');
|
|
||||||
const sshArgs = generateVpnSshCommand({
|
|
||||||
uuid,
|
|
||||||
verbose: false,
|
|
||||||
port: sshOpts.port,
|
|
||||||
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
|
|
||||||
proxyCommand: sshOpts.proxyCommand,
|
|
||||||
proxyUrl: sshOpts.proxyUrl,
|
|
||||||
username: sshOpts.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 regex = new RegExp(`\\/?${escapeRegex(serviceName)}_\\d+_\\d+`);
|
|
||||||
for (const container of lines) {
|
|
||||||
const [cId, name] = container.split(' ');
|
|
||||||
if (regex.test(name)) {
|
|
||||||
containerId = cId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerId == null) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not find a service ${serviceName} on device ${uuid}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return containerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ssh: CommandDefinition<
|
|
||||||
{
|
|
||||||
applicationOrDevice: string;
|
|
||||||
// when Capitano converts a positional parameter (but not an option)
|
|
||||||
// to a number, the original value is preserved with the _raw suffix
|
|
||||||
applicationOrDevice_raw: string;
|
|
||||||
serviceName?: string;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
port: string;
|
|
||||||
service: string;
|
|
||||||
tty: boolean;
|
|
||||||
verbose: true | undefined;
|
|
||||||
noProxy: boolean;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
signature: 'ssh <applicationOrDevice> [serviceName]',
|
|
||||||
description: 'SSH into the host or application container of a device',
|
|
||||||
primary: true,
|
|
||||||
help: stripIndent`
|
|
||||||
This command can be used to start a shell on a local or remote device.
|
|
||||||
|
|
||||||
If a service name is not provided, a shell will be opened on the host OS.
|
|
||||||
|
|
||||||
If an application name is provided, an interactive menu will be presented
|
|
||||||
for the selection of an online device. A shell will then be opened for the
|
|
||||||
host OS or service container of the chosen device.
|
|
||||||
|
|
||||||
For local devices, the IP address and .local domain name are supported.
|
|
||||||
If the device is referenced by IP or \`.local\` address, the connection
|
|
||||||
is initiated directly to balenaOS on port \`22222\` via an
|
|
||||||
openssh-compatible client. Otherwise, any connection initiated remotely
|
|
||||||
traverses the balenaCloud VPN.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
balena ssh MyApp
|
|
||||||
|
|
||||||
balena ssh f49cefd
|
|
||||||
balena ssh f49cefd my-service
|
|
||||||
balena ssh f49cefd --port <port>
|
|
||||||
|
|
||||||
balena ssh 192.168.0.1 --verbose
|
|
||||||
balena ssh f49cefd.local my-service
|
|
||||||
|
|
||||||
Warning: \`balena ssh\` requires an openssh-compatible client to be correctly
|
|
||||||
installed in your shell environment. For more information (including Windows
|
|
||||||
support) please check:
|
|
||||||
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies`,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
signature: 'port',
|
|
||||||
parameter: 'port',
|
|
||||||
description: stripIndent`
|
|
||||||
SSH server port number (default 22222) if the target is an IP address or .local
|
|
||||||
hostname. Otherwise, port number for the balenaCloud gateway (default 22).`,
|
|
||||||
alias: 'p',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'tty',
|
|
||||||
boolean: true,
|
|
||||||
description:
|
|
||||||
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
|
|
||||||
alias: 't',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'verbose',
|
|
||||||
boolean: true,
|
|
||||||
description: 'Increase verbosity',
|
|
||||||
alias: 'v',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signature: 'noproxy',
|
|
||||||
boolean: true,
|
|
||||||
description: 'Bypass global proxy configuration for the ssh connection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
action: async (params, options) => {
|
|
||||||
const applicationOrDevice =
|
|
||||||
params.applicationOrDevice_raw || params.applicationOrDevice;
|
|
||||||
const { ExpectedError } = await import('../errors');
|
|
||||||
const { getProxyConfig, which } = await import('../utils/helpers');
|
|
||||||
const { checkLoggedIn, getOnlineTargetUuid } = await import(
|
|
||||||
'../utils/patterns'
|
|
||||||
);
|
|
||||||
const { spawnSshAndExitOnError } = await import('../utils/ssh');
|
|
||||||
const sdk = getBalenaSdk();
|
|
||||||
|
|
||||||
const verbose = options.verbose === true;
|
|
||||||
const proxyConfig = getProxyConfig();
|
|
||||||
const useProxy = !!proxyConfig && !options.noProxy;
|
|
||||||
const port = options.port != null ? parseInt(options.port, 10) : undefined;
|
|
||||||
|
|
||||||
// if we're doing a direct SSH connection locally...
|
|
||||||
if (
|
|
||||||
validateDotLocalUrl(applicationOrDevice) ||
|
|
||||||
validateIPAddress(applicationOrDevice)
|
|
||||||
) {
|
|
||||||
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
|
|
||||||
return await performLocalDeviceSSH({
|
|
||||||
address: applicationOrDevice,
|
|
||||||
port,
|
|
||||||
forceTTY: options.tty === true,
|
|
||||||
verbose,
|
|
||||||
service: params.serviceName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// this will be a tunnelled SSH connection...
|
|
||||||
await checkLoggedIn();
|
|
||||||
const uuid = await getOnlineTargetUuid(sdk, applicationOrDevice);
|
|
||||||
let version: string | undefined;
|
|
||||||
let id: number | undefined;
|
|
||||||
|
|
||||||
const device = await sdk.models.device.get(uuid, {
|
|
||||||
$select: ['id', 'supervisor_version', 'is_online'],
|
|
||||||
});
|
|
||||||
id = device.id;
|
|
||||||
version = device.supervisor_version;
|
|
||||||
|
|
||||||
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
|
|
||||||
useProxy ? which('proxytunnel', false) : undefined,
|
|
||||||
sdk.auth.whoami(),
|
|
||||||
// note that `proxyUrl` refers to the balenaCloud "resin-proxy"
|
|
||||||
// service, currently "balena-devices.com", rather than some
|
|
||||||
// local proxy server URL
|
|
||||||
sdk.settings.get('proxyUrl'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getSshProxyCommand = () => {
|
|
||||||
if (!proxyConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!whichProxytunnel) {
|
|
||||||
console.warn(stripIndent`
|
|
||||||
Proxy is enabled but the \`proxytunnel\` binary cannot be found.
|
|
||||||
Please install it if you want to route the \`balena ssh\` requests through the proxy.
|
|
||||||
Alternatively you can pass \`--noproxy\` param to the \`balena ssh\` command to ignore the proxy config
|
|
||||||
for the \`ssh\` requests.
|
|
||||||
|
|
||||||
Attempting the unproxied request for now.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const p = proxyConfig;
|
|
||||||
if (p.username && p.password) {
|
|
||||||
// proxytunnel understands these variables for proxy authentication.
|
|
||||||
// Setting the variables instead of command-line options avoids the
|
|
||||||
// need for shell-specific escaping of special characters like '$'.
|
|
||||||
process.env.PROXYUSER = p.username;
|
|
||||||
process.env.PROXYPASS = p.password;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'proxytunnel',
|
|
||||||
`--proxy=${p.host}:${p.port}`,
|
|
||||||
// ssh replaces these %h:%p variables in the ProxyCommand option
|
|
||||||
// https://linux.die.net/man/5/ssh_config
|
|
||||||
'--dest=%h:%p',
|
|
||||||
...(verbose ? ['--verbose'] : []),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
|
|
||||||
|
|
||||||
if (username == null) {
|
|
||||||
throw new ExpectedError(
|
|
||||||
`Opening an SSH connection to a remote device requires you to be logged in.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, we have a long uuid with a device
|
|
||||||
// that we know exists and is accessible
|
|
||||||
let containerId: string | undefined;
|
|
||||||
if (params.serviceName != null) {
|
|
||||||
containerId = await getContainerId(
|
|
||||||
sdk,
|
|
||||||
uuid,
|
|
||||||
params.serviceName,
|
|
||||||
{
|
|
||||||
port,
|
|
||||||
proxyCommand,
|
|
||||||
proxyUrl: proxyUrl || '',
|
|
||||||
username: username!,
|
|
||||||
},
|
|
||||||
version,
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessCommand: string;
|
|
||||||
if (containerId != null) {
|
|
||||||
accessCommand = `enter ${uuid} ${containerId}`;
|
|
||||||
} else {
|
|
||||||
accessCommand = `host ${uuid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = generateVpnSshCommand({
|
|
||||||
uuid,
|
|
||||||
command: accessCommand,
|
|
||||||
verbose,
|
|
||||||
port,
|
|
||||||
proxyCommand,
|
|
||||||
proxyUrl: proxyUrl || '',
|
|
||||||
username: username!,
|
|
||||||
});
|
|
||||||
|
|
||||||
return spawnSshAndExitOnError(command);
|
|
||||||
},
|
|
||||||
};
|
|
@ -72,9 +72,6 @@ capitano.command(actions.tunnel.tunnel);
|
|||||||
// ---------- Preload Module ----------
|
// ---------- Preload Module ----------
|
||||||
capitano.command(actions.preload);
|
capitano.command(actions.preload);
|
||||||
|
|
||||||
// ---------- SSH Module ----------
|
|
||||||
capitano.command(actions.ssh.ssh);
|
|
||||||
|
|
||||||
// ---------- Local balenaOS Module ----------
|
// ---------- Local balenaOS Module ----------
|
||||||
capitano.command(actions.local.configure);
|
capitano.command(actions.local.configure);
|
||||||
capitano.command(actions.local.flash);
|
capitano.command(actions.local.flash);
|
||||||
|
@ -141,6 +141,7 @@ const EXPECTED_ERROR_REGEXES = [
|
|||||||
/^Unexpected argument/, // oclif parser: UnexpectedArgsError
|
/^Unexpected argument/, // oclif parser: UnexpectedArgsError
|
||||||
/to be one of/, // oclif parser: FlagInvalidOptionError, ArgInvalidOptionError
|
/to be one of/, // oclif parser: FlagInvalidOptionError, ArgInvalidOptionError
|
||||||
/must also be provided when using/, // oclif parser (depends-on)
|
/must also be provided when using/, // oclif parser (depends-on)
|
||||||
|
/^Expected an integer/, // oclif parser (flags.integer)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Support unit testing of handleError
|
// Support unit testing of handleError
|
||||||
|
@ -171,6 +171,7 @@ export const convertedCommands = [
|
|||||||
'os:configure',
|
'os:configure',
|
||||||
'scan',
|
'scan',
|
||||||
'settings',
|
'settings',
|
||||||
|
'ssh',
|
||||||
'tags',
|
'tags',
|
||||||
'tag:rm',
|
'tag:rm',
|
||||||
'tag:set',
|
'tag:set',
|
||||||
|
@ -10,7 +10,7 @@ Primary commands:
|
|||||||
login login to balena
|
login login to balena
|
||||||
push <applicationOrDevice> Start a remote build on the balena cloud build servers or a local mode device
|
push <applicationOrDevice> Start a remote build on the balena cloud build servers or a local mode device
|
||||||
logs <uuidOrDevice> show device logs
|
logs <uuidOrDevice> show device logs
|
||||||
ssh <applicationOrDevice> [serviceName] SSH into the host or application container of a device
|
ssh <applicationordevice> [servicename] SSH into the host or application container of a device
|
||||||
apps list all applications
|
apps list all applications
|
||||||
app <name> display information about a single application
|
app <name> display information about a single application
|
||||||
devices list all devices
|
devices list all devices
|
||||||
|
@ -125,6 +125,7 @@ describe('handleError() function', () => {
|
|||||||
'Unexpected arguments', // oclif
|
'Unexpected arguments', // oclif
|
||||||
'to be one of', // oclif
|
'to be one of', // oclif
|
||||||
'must also be provided when using', // oclif
|
'must also be provided when using', // oclif
|
||||||
|
'Expected an integer', // oclif
|
||||||
];
|
];
|
||||||
|
|
||||||
messagesToMatch.forEach((message) => {
|
messagesToMatch.forEach((message) => {
|
||||||
|
Reference in New Issue
Block a user