mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-01-18 02:39:49 +00:00
Update balena ssh command to support local devices and multicontainer
Change-type: major Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
parent
8f8d6b5f08
commit
64c2f00d2a
@ -143,7 +143,7 @@ If you come across any problems or would like to get in touch:
|
||||
|
||||
- SSH
|
||||
|
||||
- [ssh [uuid]](#ssh-uuid)
|
||||
- [ssh <applicationOrDevice> [serviceName]](#ssh-applicationordevice-servicename)
|
||||
- [tunnel <uuid>](#tunnel-uuid)
|
||||
|
||||
- Notes
|
||||
@ -981,45 +981,47 @@ increase verbosity
|
||||
|
||||
# SSH
|
||||
|
||||
## ssh [uuid]
|
||||
## ssh <applicationOrDevice> [serviceName]
|
||||
|
||||
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.
|
||||
|
||||
For local devices, the ip address and .local domain name are supported.
|
||||
|
||||
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 the README here: https://github.com/balena-io/balena-cli
|
||||
|
||||
Use this command to get a shell into the running application container of
|
||||
your device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena ssh MyApp
|
||||
$ balena ssh 7cf02a6
|
||||
$ balena ssh 7cf02a6 --port 8080
|
||||
$ balena ssh 7cf02a6 -v
|
||||
$ balena ssh 7cf02a6 -s
|
||||
$ balena ssh 7cf02a6 --noninteractive
|
||||
support) please check the information here:
|
||||
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies
|
||||
|
||||
### Options
|
||||
|
||||
#### --port, -p <port>
|
||||
|
||||
ssh gateway port
|
||||
SSH gateway port
|
||||
|
||||
#### --verbose, -v
|
||||
|
||||
increase verbosity
|
||||
|
||||
#### --host, -s
|
||||
|
||||
access host OS (for devices with balenaOS >= 2.0.0+rev1)
|
||||
Increase verbosity
|
||||
|
||||
#### --noproxy
|
||||
|
||||
don't use the proxy configuration for this connection. Only makes sense if you've configured proxy globally.
|
||||
|
||||
#### --noninteractive
|
||||
|
||||
run command non-interactively, do not automatically suggest devices to connect to if UUID not found
|
||||
Don't use the proxy configuration for this connection. This flag
|
||||
only make sense if you've configured a proxy globally.
|
||||
|
||||
## tunnel <uuid>
|
||||
|
||||
@ -1430,7 +1432,7 @@ Path to a local docker socket (e.g. /var/run/docker.sock)
|
||||
|
||||
Docker daemon hostname or IP address (dev machine or balena device)
|
||||
|
||||
#### --dockerPort <dockerPort>
|
||||
#### --dockerPort, -p <dockerPort>
|
||||
|
||||
Docker daemon TCP port number (hint: 2375 for balena devices)
|
||||
|
||||
|
@ -1,149 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 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.
|
||||
###
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
module.exports =
|
||||
signature: 'ssh [uuid]'
|
||||
description: 'get a shell into the running app container of a device'
|
||||
help: '''
|
||||
Warning: 'balena ssh' requires an openssh-compatible client to be correctly
|
||||
installed in your shell environment. For more information (including Windows
|
||||
support) please check the README here: https://github.com/balena-io/balena-cli
|
||||
|
||||
Use this command to get a shell into the running application container of
|
||||
your device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena ssh MyApp
|
||||
$ balena ssh 7cf02a6
|
||||
$ balena ssh 7cf02a6 --port 8080
|
||||
$ balena ssh 7cf02a6 -v
|
||||
$ balena ssh 7cf02a6 -s
|
||||
$ balena ssh 7cf02a6 --noninteractive
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
options: [
|
||||
signature: 'port'
|
||||
parameter: 'port'
|
||||
description: 'ssh gateway port'
|
||||
alias: 'p'
|
||||
,
|
||||
signature: 'verbose'
|
||||
boolean: true
|
||||
description: 'increase verbosity'
|
||||
alias: 'v'
|
||||
commandOptions.hostOSAccess,
|
||||
signature: 'noproxy'
|
||||
boolean: true
|
||||
description: "don't use the proxy configuration for this connection.
|
||||
Only makes sense if you've configured proxy globally."
|
||||
,
|
||||
signature: 'noninteractive'
|
||||
boolean: true
|
||||
description: 'run command non-interactively, do not automatically suggest devices to connect to if UUID not found'
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(params)
|
||||
child_process = require('child_process')
|
||||
Promise = require('bluebird')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
_ = require('lodash')
|
||||
bash = require('bash')
|
||||
hasbin = require('hasbin')
|
||||
{ getSubShellCommand } = require('../utils/helpers')
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
options.port ?= 22
|
||||
|
||||
verbose = if options.verbose then '-vvv' else ''
|
||||
|
||||
proxyConfig = global.PROXY_CONFIG
|
||||
useProxy = !!proxyConfig and not options.noproxy
|
||||
|
||||
getSshProxyCommand = (hasTunnelBin) ->
|
||||
return '' if not useProxy
|
||||
|
||||
if not hasTunnelBin
|
||||
console.warn('''
|
||||
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.
|
||||
|
||||
Attemmpting the unproxied request for now.
|
||||
''')
|
||||
return ''
|
||||
|
||||
tunnelOptions =
|
||||
proxy: "#{proxyConfig.host}:#{proxyConfig.port}"
|
||||
dest: '%h:%p'
|
||||
{ proxyAuth } = proxyConfig
|
||||
if proxyAuth
|
||||
i = proxyAuth.indexOf(':')
|
||||
_.assign tunnelOptions,
|
||||
user: proxyAuth.substring(0, i)
|
||||
pass: proxyAuth.substring(i + 1)
|
||||
proxyCommand = "proxytunnel #{bash.args(tunnelOptions, '--', '=')}"
|
||||
return "-o #{bash.args({ ProxyCommand: proxyCommand }, '', '=')}"
|
||||
|
||||
Promise.try ->
|
||||
return false if not params.uuid
|
||||
return balena.models.device.has(params.uuid)
|
||||
.then (uuidExists) ->
|
||||
return params.uuid if uuidExists
|
||||
if options.noninteractive
|
||||
console.error("Could not find device: #{params.uuid}")
|
||||
process.exit(1)
|
||||
return patterns.inferOrSelectDevice()
|
||||
.then (uuid) ->
|
||||
console.info("Connecting to: #{uuid}")
|
||||
balena.models.device.get(uuid)
|
||||
.then (device) ->
|
||||
patterns.exitWithExpectedError('Device is not online') if not device.is_online
|
||||
|
||||
Promise.props
|
||||
username: balena.auth.whoami()
|
||||
uuid: device.uuid
|
||||
# get full uuid
|
||||
containerId: if options.host then '' else balena.models.device.getApplicationInfo(device.uuid).get('containerId')
|
||||
proxyUrl: balena.settings.get('proxyUrl')
|
||||
|
||||
hasTunnelBin: if useProxy then hasbin('proxytunnel') else null
|
||||
.then ({ username, uuid, containerId, proxyUrl, hasTunnelBin }) ->
|
||||
throw new Error('Did not find running application container') if not containerId?
|
||||
Promise.try ->
|
||||
sshProxyCommand = getSshProxyCommand(hasTunnelBin)
|
||||
|
||||
if options.host
|
||||
accessCommand = "host #{uuid}"
|
||||
else
|
||||
accessCommand = "enter #{uuid} #{containerId}"
|
||||
|
||||
command = "ssh #{verbose} -t \
|
||||
-o LogLevel=ERROR \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
#{sshProxyCommand} \
|
||||
-p #{options.port} #{username}@ssh.#{proxyUrl} #{accessCommand}"
|
||||
|
||||
subShellCommand = getSubShellCommand(command)
|
||||
child_process.spawn subShellCommand.program, subShellCommand.args,
|
||||
stdio: 'inherit'
|
||||
.nodeify(done)
|
502
lib/actions/ssh.ts
Normal file
502
lib/actions/ssh.ts
Normal file
@ -0,0 +1,502 @@
|
||||
/*
|
||||
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';
|
||||
|
||||
import { BalenaApplicationNotFound, BalenaDeviceNotFound } from 'balena-errors';
|
||||
import {
|
||||
validateApplicationName,
|
||||
validateDotLocalUrl,
|
||||
validateIPAddress,
|
||||
validateShortUuid,
|
||||
validateUuid,
|
||||
} from '../utils/validation';
|
||||
|
||||
enum SSHTarget {
|
||||
APPLICATION,
|
||||
DEVICE,
|
||||
LOCAL_DEVICE,
|
||||
}
|
||||
|
||||
async function getSSHTarget(
|
||||
sdk: BalenaSdk.BalenaSDK,
|
||||
applicationOrDevice: string,
|
||||
): Promise<{
|
||||
target: SSHTarget;
|
||||
deviceChecked?: boolean;
|
||||
applicationChecked?: boolean;
|
||||
device?: BalenaSdk.Device;
|
||||
} | null> {
|
||||
if (
|
||||
validateDotLocalUrl(applicationOrDevice) ||
|
||||
validateIPAddress(applicationOrDevice)
|
||||
) {
|
||||
return { target: SSHTarget.LOCAL_DEVICE };
|
||||
}
|
||||
|
||||
const appTest = validateApplicationName(applicationOrDevice);
|
||||
const uuidTest = validateUuid(applicationOrDevice);
|
||||
if (appTest || uuidTest) {
|
||||
// Do some further processing to work out which it is
|
||||
if (appTest && !uuidTest) {
|
||||
return {
|
||||
target: SSHTarget.APPLICATION,
|
||||
applicationChecked: false,
|
||||
};
|
||||
}
|
||||
if (uuidTest && !appTest) {
|
||||
return {
|
||||
target: SSHTarget.DEVICE,
|
||||
deviceChecked: false,
|
||||
};
|
||||
}
|
||||
|
||||
// This is the harder part, we have a string that
|
||||
// fulfills both the uuid and application name
|
||||
// requirements. We should go away and test for both a
|
||||
// device with that uuid, and an application with that
|
||||
// name, and choose the appropriate one
|
||||
try {
|
||||
await sdk.models.application.get(applicationOrDevice);
|
||||
return { target: SSHTarget.APPLICATION, applicationChecked: true };
|
||||
} catch (e) {
|
||||
if (e instanceof BalenaApplicationNotFound) {
|
||||
// Here we want to check for a device with that UUID
|
||||
try {
|
||||
const device = await sdk.models.device.get(applicationOrDevice, {
|
||||
$select: ['id', 'uuid', 'supervisor_version', 'is_online'],
|
||||
});
|
||||
return { target: SSHTarget.DEVICE, deviceChecked: true, device };
|
||||
} catch (err) {
|
||||
if (err instanceof BalenaDeviceNotFound) {
|
||||
throw new Error(
|
||||
`Device or application not found: ${applicationOrDevice}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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(
|
||||
`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.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;
|
||||
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',
|
||||
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.
|
||||
|
||||
For local devices, the ip address and .local domain name are supported.
|
||||
|
||||
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`,
|
||||
permission: 'user',
|
||||
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) => {
|
||||
const map = await import('lodash/map');
|
||||
const bash = await import('bash');
|
||||
// TODO: Make this typed
|
||||
const hasbin = require('hasbin');
|
||||
const { getSubShellCommand } = await import('../utils/helpers');
|
||||
const { child_process } = await import('mz');
|
||||
|
||||
const { exitWithExpectedError, selectFromList } = await import(
|
||||
'../utils/patterns'
|
||||
);
|
||||
const sdk = BalenaSdk.fromSharedOptions();
|
||||
|
||||
const verbose = options.verbose === true;
|
||||
// ugh TODO: Fix this
|
||||
const proxyConfig = (global as any).PROXY_CONFIG;
|
||||
const useProxy = !!proxyConfig && !options.noProxy;
|
||||
const port = options.port != null ? parseInt(options.port, 10) : undefined;
|
||||
|
||||
const getSshProxyCommand = (hasTunnelBin: boolean) => {
|
||||
if (!useProxy) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!hasTunnelBin) {
|
||||
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 '';
|
||||
}
|
||||
|
||||
let tunnelOptions: Dictionary<string> = {
|
||||
proxy: `${proxyConfig.host}:${proxyConfig.port}`,
|
||||
dest: '%h:%p',
|
||||
};
|
||||
const { proxyAuth } = proxyConfig;
|
||||
if (proxyAuth) {
|
||||
const i = proxyAuth.indexOf(':');
|
||||
tunnelOptions = {
|
||||
user: proxyAuth.substring(0, i),
|
||||
pass: proxyAuth.substring(i + 1),
|
||||
...tunnelOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const proxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
|
||||
return `-o ${bash.args({ ProxyCommand: proxyCommand }, '', '=')}`;
|
||||
};
|
||||
|
||||
// Detect what type of SSH we're doing
|
||||
const maybeParamChecks = await getSSHTarget(
|
||||
sdk,
|
||||
params.applicationOrDevice,
|
||||
);
|
||||
if (maybeParamChecks == null) {
|
||||
exitWithExpectedError(
|
||||
new Error(stripIndent`
|
||||
Could not parse SSH target.
|
||||
You can provide an application name, IP address or .local address`),
|
||||
);
|
||||
}
|
||||
const paramChecks = maybeParamChecks!;
|
||||
|
||||
switch (paramChecks.target) {
|
||||
case SSHTarget.APPLICATION:
|
||||
// Here what we want to do is fetch all device which
|
||||
// are part of this application, and online
|
||||
try {
|
||||
const devices = await sdk.models.device.getAllByApplication(
|
||||
params.applicationOrDevice,
|
||||
{ $filter: { is_online: true }, $select: ['device_name', 'uuid'] },
|
||||
);
|
||||
const choice = await selectFromList(
|
||||
'Please choose an online device to SSH into:',
|
||||
map(devices, ({ device_name, uuid: uuidToChoose }) => ({
|
||||
name: `${device_name} [${uuidToChoose.substr(0, 7)}]`,
|
||||
uuid: uuidToChoose,
|
||||
})),
|
||||
);
|
||||
// A little bit hacky, but it means we can fall
|
||||
// through to the next handling mechanism
|
||||
params.applicationOrDevice = choice.uuid;
|
||||
paramChecks.deviceChecked = true;
|
||||
} catch (e) {
|
||||
if (e instanceof BalenaApplicationNotFound) {
|
||||
exitWithExpectedError(
|
||||
`Could not find an application named ${
|
||||
params.applicationOrDevice
|
||||
}`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
case SSHTarget.DEVICE:
|
||||
// We want to do two things here; firstly, check
|
||||
// that the device exists and is accessible, and
|
||||
// also convert a short uuid to a long one if
|
||||
// necessary
|
||||
let uuid = params.applicationOrDevice;
|
||||
let version: string | undefined;
|
||||
let id: number | undefined;
|
||||
let isOnline: boolean | undefined;
|
||||
// We also want to avoid checking for a device if we
|
||||
// know it exists
|
||||
if (!paramChecks.deviceChecked || validateShortUuid(uuid)) {
|
||||
try {
|
||||
const device = await sdk.models.device.get(uuid, {
|
||||
$select: ['id', 'uuid', 'supervisor_version', 'is_online'],
|
||||
});
|
||||
uuid = device.uuid;
|
||||
version = device.supervisor_version;
|
||||
id = device.id;
|
||||
isOnline = device.is_online;
|
||||
} catch (e) {
|
||||
if (e instanceof BalenaDeviceNotFound) {
|
||||
exitWithExpectedError(`Could not find device: ${uuid}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
version = paramChecks.device!.supervisor_version;
|
||||
uuid = paramChecks.device!.uuid;
|
||||
id = paramChecks.device!.id;
|
||||
isOnline = paramChecks.device!.is_online;
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
throw new Error(`Device ${uuid} is not online.`);
|
||||
}
|
||||
|
||||
const [hasTunnelBin, username, proxyUrl] = await Promise.all([
|
||||
useProxy ? await hasbin('proxytunnel') : undefined,
|
||||
sdk.auth.whoami(),
|
||||
sdk.settings.get('proxyUrl'),
|
||||
]);
|
||||
const proxyCommand = getSshProxyCommand(hasTunnelBin);
|
||||
|
||||
if (username == null) {
|
||||
exitWithExpectedError(
|
||||
`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,
|
||||
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,
|
||||
username: username!,
|
||||
});
|
||||
|
||||
const subShellCommand = getSubShellCommand(command);
|
||||
await child_process.spawn(
|
||||
subShellCommand.program,
|
||||
subShellCommand.args,
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
break;
|
||||
case SSHTarget.LOCAL_DEVICE:
|
||||
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
|
||||
await performLocalDeviceSSH({
|
||||
address: params.applicationOrDevice,
|
||||
port,
|
||||
verbose,
|
||||
service: params.serviceName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
@ -121,7 +121,7 @@ capitano.command(actions.tunnel.tunnel)
|
||||
capitano.command(actions.preload)
|
||||
|
||||
# ---------- SSH Module ----------
|
||||
capitano.command(actions.ssh)
|
||||
capitano.command(actions.ssh.ssh)
|
||||
|
||||
# ---------- Local balenaOS Module ----------
|
||||
capitano.command(actions.local.configure)
|
||||
|
118
lib/utils/device/ssh.ts
Normal file
118
lib/utils/device/ssh.ts
Normal file
@ -0,0 +1,118 @@
|
||||
/*
|
||||
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 { ContainerInfo } from 'dockerode';
|
||||
|
||||
export interface DeviceSSHOpts {
|
||||
address: string;
|
||||
port?: number;
|
||||
verbose: boolean;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
|
||||
|
||||
export async function performLocalDeviceSSH(
|
||||
opts: DeviceSSHOpts,
|
||||
): Promise<void> {
|
||||
const childProcess = await import('child_process');
|
||||
const reduce = await import('lodash/reduce');
|
||||
const { getSubShellCommand } = await import('../helpers');
|
||||
const { exitWithExpectedError } = await import('../patterns');
|
||||
const { stripIndent } = await import('common-tags');
|
||||
|
||||
let command = '';
|
||||
|
||||
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 escapeRegex = await import('lodash/escapeRegExp');
|
||||
const docker = new Docker({
|
||||
host: opts.address,
|
||||
port: 2375,
|
||||
});
|
||||
|
||||
const regex = new RegExp(`\\/?${escapeRegex(opts.service)}_\\d+_\\d+`);
|
||||
const nameRegex = /\/?([a-zA-Z0-9_]+)_\d+_\d+/;
|
||||
let allContainers: ContainerInfo[];
|
||||
try {
|
||||
allContainers = await docker.listContainers();
|
||||
} catch (_e) {
|
||||
exitWithExpectedError(stripIndent`
|
||||
Could not access docker daemon on device ${opts.address}.
|
||||
Please ensure the device is in local mode.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceNames: string[] = [];
|
||||
const containers = allContainers
|
||||
.map(container => {
|
||||
for (const name of container.Names) {
|
||||
if (regex.test(name)) {
|
||||
return { id: container.Id, name };
|
||||
}
|
||||
const match = name.match(nameRegex);
|
||||
if (match) {
|
||||
serviceNames.push(match[1]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
})
|
||||
.filter(c => c != null);
|
||||
|
||||
if (containers.length === 0) {
|
||||
exitWithExpectedError(
|
||||
`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) {
|
||||
exitWithExpectedError(stripIndent`
|
||||
Found more than one container with a service name ${opts.service}.
|
||||
This state is not supported, please contact support.
|
||||
`);
|
||||
}
|
||||
|
||||
const shellCmd = `/bin/sh -c $"'if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi'"`;
|
||||
command = `'${deviceContainerEngineBinary}' exec -ti ${
|
||||
containers[0]!.id
|
||||
} ${shellCmd}`;
|
||||
}
|
||||
// Generate the SSH command
|
||||
const sshCommand = `ssh \
|
||||
${opts.verbose ? '-vvv' : ''} \
|
||||
-t \
|
||||
-p ${opts.port ? opts.port : 22222} \
|
||||
-o LogLevel=ERROR \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
root@${opts.address} ${command}`;
|
||||
|
||||
const subShell = getSubShellCommand(sshCommand);
|
||||
childProcess.spawn(subShell.program, subShell.args, { stdio: 'inherit' });
|
||||
}
|
@ -22,6 +22,7 @@ const IP_REGEX = new RegExp(
|
||||
/\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/,
|
||||
);
|
||||
const DOTLOCAL_REGEX = new RegExp(/^([a-zA-Z0-9-]+\.)+local$/);
|
||||
const UUID_REGEX = new RegExp(/^[0-9a-f]+$/);
|
||||
|
||||
export function validateEmail(input: string) {
|
||||
if (!validEmail(input)) {
|
||||
@ -54,3 +55,21 @@ export function validateIPAddress(input: string): boolean {
|
||||
export function validateDotLocalUrl(input: string): boolean {
|
||||
return DOTLOCAL_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function validateLongUuid(input: string): boolean {
|
||||
if (input.length !== 32 && input.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
return UUID_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function validateShortUuid(input: string): boolean {
|
||||
if (input.length !== 7) {
|
||||
return false;
|
||||
}
|
||||
return UUID_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function validateUuid(input: string): boolean {
|
||||
return validateLongUuid(input) || validateShortUuid(input);
|
||||
}
|
||||
|
1
typings/bash/index.d.ts
vendored
Normal file
1
typings/bash/index.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'bash';
|
Loading…
Reference in New Issue
Block a user