2019-04-24 17:45:19 +00:00
|
|
|
/*
|
|
|
|
Copyright 2016-2019 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 * as BalenaSdk from 'balena-sdk';
|
|
|
|
import { CommandDefinition } from 'capitano';
|
|
|
|
import { stripIndent } from 'common-tags';
|
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
import { BalenaDeviceNotFound } from 'balena-errors';
|
|
|
|
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
|
2019-04-24 17:45:19 +00:00
|
|
|
|
|
|
|
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('resin-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(
|
2020-01-20 21:21:05 +00:00
|
|
|
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
|
2019-04-24 17:45:19 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
const body = request.body;
|
|
|
|
if (body.status !== 'success') {
|
|
|
|
throw new Error(
|
2020-01-20 21:21:05 +00:00
|
|
|
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
|
2019-04-24 17:45:19 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
containerId = body.services[serviceName];
|
|
|
|
} else {
|
|
|
|
console.log(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 { child_process } = await import('mz');
|
|
|
|
const escapeRegex = await import('lodash/escapeRegExp');
|
|
|
|
const { getSubShellCommand } = await import('../utils/helpers');
|
|
|
|
const { deviceContainerEngineBinary } = await import('../utils/device/ssh');
|
|
|
|
|
|
|
|
const command = generateVpnSshCommand({
|
|
|
|
uuid,
|
|
|
|
verbose: false,
|
|
|
|
port: sshOpts.port,
|
|
|
|
command: `host ${uuid} '"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"'`,
|
|
|
|
proxyCommand: sshOpts.proxyCommand,
|
|
|
|
proxyUrl: sshOpts.proxyUrl,
|
|
|
|
username: sshOpts.username,
|
|
|
|
});
|
|
|
|
|
|
|
|
const subShellCommand = getSubShellCommand(command);
|
|
|
|
const subprocess = child_process.spawn(
|
|
|
|
subShellCommand.program,
|
|
|
|
subShellCommand.args,
|
|
|
|
{
|
|
|
|
stdio: [null, 'pipe', null],
|
|
|
|
},
|
|
|
|
);
|
|
|
|
const containers = await new Promise<string>((resolve, reject) => {
|
|
|
|
let output = '';
|
|
|
|
subprocess.stdout.on('data', chunk => (output += 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);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
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 (
|
|
|
|
`ssh ${
|
|
|
|
opts.verbose ? '-vvv' : ''
|
|
|
|
} -t -o LogLevel=ERROR -o StrictHostKeyChecking=no ` +
|
|
|
|
`-o UserKnownHostsFile=/dev/null ` +
|
|
|
|
`${opts.proxyCommand != null ? opts.proxyCommand : ''} ` +
|
|
|
|
`${opts.port != null ? `-p ${opts.port}` : ''} ` +
|
|
|
|
`${opts.username}@ssh.${opts.proxyUrl} ${opts.command}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export const ssh: CommandDefinition<
|
|
|
|
{
|
|
|
|
applicationOrDevice: string;
|
2019-06-26 10:44:16 +00:00
|
|
|
// 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;
|
2019-04-24 17:45:19 +00:00
|
|
|
serviceName?: string;
|
|
|
|
},
|
|
|
|
{
|
|
|
|
port: string;
|
|
|
|
service: string;
|
|
|
|
verbose: true | undefined;
|
|
|
|
noProxy: boolean;
|
|
|
|
}
|
|
|
|
> = {
|
|
|
|
signature: 'ssh <applicationOrDevice> [serviceName]',
|
|
|
|
description: 'SSH into the host or application container of a device',
|
2019-05-16 07:53:24 +00:00
|
|
|
primary: true,
|
2019-04-24 17:45:19 +00:00
|
|
|
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, all online devices in the application
|
|
|
|
will be presented, and the chosen device will then have a shell opened on
|
|
|
|
in it's service container or host OS.
|
|
|
|
|
2019-08-09 20:00:20 +00:00
|
|
|
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.
|
2019-04-24 17:45:19 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2019-08-09 20:00:20 +00:00
|
|
|
Warning: \`balena ssh\` requires an openssh-compatible client to be correctly
|
2019-04-24 17:45:19 +00:00
|
|
|
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: 'SSH gateway port',
|
|
|
|
alias: 'p',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signature: 'verbose',
|
|
|
|
boolean: true,
|
|
|
|
description: 'Increase verbosity',
|
|
|
|
alias: 'v',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signature: 'noproxy',
|
|
|
|
boolean: true,
|
|
|
|
description: stripIndent`
|
|
|
|
Don't use the proxy configuration for this connection. This flag
|
|
|
|
only make sense if you've configured a proxy globally.`,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
action: async (params, options) => {
|
2019-06-26 10:44:16 +00:00
|
|
|
const applicationOrDevice =
|
|
|
|
params.applicationOrDevice_raw || params.applicationOrDevice;
|
2019-04-24 17:45:19 +00:00
|
|
|
const bash = await import('bash');
|
2020-01-24 18:43:04 +00:00
|
|
|
const { getProxyConfig, getSubShellCommand, which } = await import(
|
|
|
|
'../utils/helpers'
|
|
|
|
);
|
2019-04-24 17:45:19 +00:00
|
|
|
const { child_process } = await import('mz');
|
2019-06-06 10:24:44 +00:00
|
|
|
const {
|
|
|
|
exitIfNotLoggedIn,
|
|
|
|
exitWithExpectedError,
|
|
|
|
getOnlineTargetUuid,
|
|
|
|
} = await import('../utils/patterns');
|
2019-04-24 17:45:19 +00:00
|
|
|
const sdk = BalenaSdk.fromSharedOptions();
|
|
|
|
|
|
|
|
const verbose = options.verbose === true;
|
2020-01-24 18:43:04 +00:00
|
|
|
const proxyConfig = getProxyConfig();
|
2019-04-24 17:45:19 +00:00
|
|
|
const useProxy = !!proxyConfig && !options.noProxy;
|
|
|
|
const port = options.port != null ? parseInt(options.port, 10) : undefined;
|
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
// if we're doing a direct SSH connection locally...
|
|
|
|
if (
|
2019-06-26 10:44:16 +00:00
|
|
|
validateDotLocalUrl(applicationOrDevice) ||
|
|
|
|
validateIPAddress(applicationOrDevice)
|
2019-06-06 10:24:44 +00:00
|
|
|
) {
|
|
|
|
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
|
|
|
|
return await performLocalDeviceSSH({
|
2019-06-26 10:44:16 +00:00
|
|
|
address: applicationOrDevice,
|
2019-06-06 10:24:44 +00:00
|
|
|
port,
|
|
|
|
verbose,
|
|
|
|
service: params.serviceName,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// this will be a tunnelled SSH connection...
|
2019-10-30 15:30:31 +00:00
|
|
|
await exitIfNotLoggedIn();
|
2019-06-26 10:44:16 +00:00
|
|
|
const uuid = await getOnlineTargetUuid(sdk, applicationOrDevice);
|
2019-06-06 10:24:44 +00:00
|
|
|
let version: string | undefined;
|
|
|
|
let id: number | undefined;
|
|
|
|
|
|
|
|
try {
|
|
|
|
const device = await sdk.models.device.get(uuid, {
|
|
|
|
$select: ['id', 'supervisor_version', 'is_online'],
|
|
|
|
});
|
|
|
|
id = device.id;
|
|
|
|
version = device.supervisor_version;
|
|
|
|
} catch (e) {
|
|
|
|
if (e instanceof BalenaDeviceNotFound) {
|
|
|
|
exitWithExpectedError(`Could not find device: ${uuid}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-24 18:43:04 +00:00
|
|
|
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
|
|
|
|
useProxy ? which('proxytunnel', false) : undefined,
|
2019-06-06 10:24:44 +00:00
|
|
|
sdk.auth.whoami(),
|
|
|
|
sdk.settings.get('proxyUrl'),
|
|
|
|
]);
|
|
|
|
|
|
|
|
const getSshProxyCommand = () => {
|
2019-04-24 17:45:19 +00:00
|
|
|
if (!useProxy) {
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
|
2020-01-24 18:43:04 +00:00
|
|
|
if (!whichProxytunnel) {
|
2019-04-24 17:45:19 +00:00
|
|
|
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 '';
|
|
|
|
}
|
|
|
|
|
2020-01-24 18:43:04 +00:00
|
|
|
const p = proxyConfig!;
|
|
|
|
const tunnelOptions: Dictionary<string> = {
|
|
|
|
proxy: `${p.host}:${p.port}`,
|
2019-04-24 17:45:19 +00:00
|
|
|
dest: '%h:%p',
|
|
|
|
};
|
2020-01-24 18:43:04 +00:00
|
|
|
if (p.username && p.password) {
|
|
|
|
tunnelOptions.user = p.username;
|
|
|
|
tunnelOptions.pass = p.password;
|
2019-04-24 17:45:19 +00:00
|
|
|
}
|
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
const ProxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
|
|
|
|
return `-o ${bash.args({ ProxyCommand }, '', '=')}`;
|
2019-04-24 17:45:19 +00:00
|
|
|
};
|
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
const proxyCommand = getSshProxyCommand();
|
|
|
|
|
|
|
|
if (username == null) {
|
2019-04-24 17:45:19 +00:00
|
|
|
exitWithExpectedError(
|
2019-06-06 10:24:44 +00:00
|
|
|
`Opening an SSH connection to a remote device requires you to be logged in.`,
|
2019-04-24 17:45:19 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
// 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,
|
|
|
|
{
|
2019-04-24 17:45:19 +00:00
|
|
|
port,
|
|
|
|
proxyCommand,
|
2020-01-24 18:43:04 +00:00
|
|
|
proxyUrl: proxyUrl || '',
|
2019-04-24 17:45:19 +00:00
|
|
|
username: username!,
|
2019-06-06 10:24:44 +00:00
|
|
|
},
|
|
|
|
version,
|
|
|
|
id,
|
|
|
|
);
|
|
|
|
}
|
2019-04-24 17:45:19 +00:00
|
|
|
|
2019-06-06 10:24:44 +00:00
|
|
|
let accessCommand: string;
|
|
|
|
if (containerId != null) {
|
|
|
|
accessCommand = `enter ${uuid} ${containerId}`;
|
|
|
|
} else {
|
|
|
|
accessCommand = `host ${uuid}`;
|
2019-04-24 17:45:19 +00:00
|
|
|
}
|
2019-06-06 10:24:44 +00:00
|
|
|
|
|
|
|
const command = generateVpnSshCommand({
|
|
|
|
uuid,
|
|
|
|
command: accessCommand,
|
|
|
|
verbose,
|
|
|
|
port,
|
|
|
|
proxyCommand,
|
2020-01-24 18:43:04 +00:00
|
|
|
proxyUrl: proxyUrl || '',
|
2019-06-06 10:24:44 +00:00
|
|
|
username: username!,
|
|
|
|
});
|
|
|
|
|
|
|
|
const subShellCommand = getSubShellCommand(command);
|
|
|
|
await child_process.spawn(subShellCommand.program, subShellCommand.args, {
|
|
|
|
stdio: 'inherit',
|
|
|
|
});
|
2019-04-24 17:45:19 +00:00
|
|
|
},
|
|
|
|
};
|