balena-cli/lib/commands/ssh.ts

233 lines
7.2 KiB
TypeScript
Raw Normal View History

/**
* @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, validateLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
port?: number;
tty: boolean;
verbose: boolean;
noproxy: boolean;
help: void;
}
interface ArgsDef {
fleetOrDevice: string;
service?: string;
}
export default class SshCmd extends Command {
public static description = stripIndent`
Open a SSH prompt on a device's host OS or service container.
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 fleet 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 MyFleet',
'$ 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: 'fleetOrDevice',
description:
'fleet name/slug/id, device uuid, or address of local device',
required: true,
},
{
name: 'service',
description: 'service name, if connecting to a container',
required: false,
},
];
public static usage = 'ssh <fleetOrDevice> [service]';
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({
default: false,
description:
'force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
char: 't',
}),
verbose: flags.boolean({
default: false,
description: 'increase verbosity',
char: 'v',
}),
noproxy: flags.boolean({
default: false,
description: 'bypass global proxy configuration for the ssh connection',
}),
help: cf.help,
};
public static primary = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
SshCmd,
);
// Local connection
if (validateLocalHostnameOrIp(params.fleetOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
hostname: params.fleetOrDevice,
port: options.port || 'local',
forceTTY: options.tty,
verbose: options.verbose,
service: params.service,
});
}
// Remote connection
const { getProxyConfig } = await import('../utils/helpers');
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const sdk = getBalenaSdk();
const proxyConfig = getProxyConfig();
const useProxy = !!proxyConfig && !options.noproxy;
// this will be a tunnelled SSH connection...
await Command.checkNotUsingOfflineMode();
await Command.checkLoggedIn();
const deviceUuid = await getOnlineTargetDeviceUuid(
sdk,
params.fleetOrDevice,
);
const { which } = await import('../utils/which');
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;
// At this point, we have a long uuid of a device
// that we know exists and is accessible
let containerId: string | undefined;
if (params.service != null) {
const { getContainerIdForService } = await import('../utils/device/ssh');
containerId = await getContainerIdForService({
deviceUuid,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
service: params.service,
username: username!,
});
}
let accessCommand: string;
if (containerId != null) {
accessCommand = `enter ${deviceUuid} ${containerId}`;
} else {
accessCommand = `host ${deviceUuid}`;
}
const { runRemoteCommand } = await import('../utils/ssh');
await runRemoteCommand({
cmd: accessCommand,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
username,
verbose: options.verbose,
});
}
}