Update commands ssh, tunnel to support orgs

Change-type: patch
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe
2020-12-16 16:57:25 +01:00
parent f128eaf389
commit 9d2884aab7
7 changed files with 142 additions and 141 deletions

View File

@ -1768,7 +1768,7 @@ produce JSON output instead of tabular output
Start a shell on a local or remote device. If a service name is not provided, 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. a shell will be opened on the host OS.
If an application name is provided, an interactive menu will be presented If an application 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
host OS or service container of the chosen device. host OS or service container of the chosen device.
@ -1803,7 +1803,7 @@ Examples:
#### APPLICATIONORDEVICE #### APPLICATIONORDEVICE
application name, device uuid, or address of local device application name/slug/id, device uuid, or address of local device
#### SERVICE #### SERVICE
@ -1818,15 +1818,15 @@ hostname. Otherwise, port number for the balenaCloud gateway (default 22).
#### -t, --tty #### -t, --tty
Force pseudo-terminal allocation (bypass TTY autodetection for stdin) force pseudo-terminal allocation (bypass TTY autodetection for stdin)
#### -v, --verbose #### -v, --verbose
Increase verbosity increase verbosity
#### --noproxy #### --noproxy
Bypass global proxy configuration for the ssh connection bypass global proxy configuration for the ssh connection
## tunnel &#60;deviceOrApplication&#62; ## tunnel &#60;deviceOrApplication&#62;
@ -1863,7 +1863,7 @@ Examples:
#### DEVICEORAPPLICATION #### DEVICEORAPPLICATION
device uuid or application name/id device uuid or application name/slug/id
### Options ### Options

View File

@ -19,11 +19,7 @@ import { flags } from '@oclif/command';
import Command from '../command'; import Command from '../command';
import * as cf from '../utils/common-flags'; import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy'; import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
parseAsInteger,
validateDotLocalUrl,
validateIPAddress,
} from '../utils/validation';
import * as BalenaSdk from 'balena-sdk'; import * as BalenaSdk from 'balena-sdk';
interface FlagsDef { interface FlagsDef {
@ -39,14 +35,14 @@ interface ArgsDef {
service?: string; service?: string;
} }
export default class NoteCmd extends Command { export default class SshCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
SSH into the host or application container of a device. 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, 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. a shell will be opened on the host OS.
If an application name is provided, an interactive menu will be presented If an application 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
host OS or service container of the chosen device. host OS or service container of the chosen device.
@ -81,7 +77,8 @@ export default class NoteCmd extends Command {
public static args = [ public static args = [
{ {
name: 'applicationOrDevice', name: 'applicationOrDevice',
description: 'application name, device uuid, or address of local device', description:
'application name/slug/id, device uuid, or address of local device',
required: true, required: true,
}, },
{ {
@ -104,17 +101,17 @@ export default class NoteCmd extends Command {
tty: flags.boolean({ tty: flags.boolean({
default: false, default: false,
description: description:
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)', 'force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
char: 't', char: 't',
}), }),
verbose: flags.boolean({ verbose: flags.boolean({
default: false, default: false,
description: 'Increase verbosity', description: 'increase verbosity',
char: 'v', char: 'v',
}), }),
noproxy: flags.boolean({ noproxy: flags.boolean({
default: false, default: false,
description: 'Bypass global proxy configuration for the ssh connection', description: 'bypass global proxy configuration for the ssh connection',
}), }),
help: cf.help, help: cf.help,
}; };
@ -123,14 +120,11 @@ export default class NoteCmd extends Command {
public async run() { public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>( const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
NoteCmd, SshCmd,
); );
// if we're doing a direct SSH connection locally... // Local connection
if ( if (validateLocalHostnameOrIp(params.applicationOrDevice)) {
validateDotLocalUrl(params.applicationOrDevice) ||
validateIPAddress(params.applicationOrDevice)
) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh'); const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({ return await performLocalDeviceSSH({
address: params.applicationOrDevice, address: params.applicationOrDevice,
@ -141,26 +135,27 @@ export default class NoteCmd extends Command {
}); });
} }
// Remote connection
const { getProxyConfig, which } = await import('../utils/helpers'); const { getProxyConfig, which } = await import('../utils/helpers');
const { checkLoggedIn, getOnlineTargetUuid } = await import( const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
'../utils/patterns'
);
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const proxyConfig = getProxyConfig(); const proxyConfig = getProxyConfig();
const useProxy = !!proxyConfig && !options.noproxy; const useProxy = !!proxyConfig && !options.noproxy;
// this will be a tunnelled SSH connection... // this will be a tunnelled SSH connection...
await checkLoggedIn(); await Command.checkLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice); const deviceUuid = await getOnlineTargetDeviceUuid(
let version: string | undefined; sdk,
let id: number | undefined; params.applicationOrDevice,
);
const device = await sdk.models.device.get(uuid, { const device = await sdk.models.device.get(deviceUuid, {
$select: ['id', 'supervisor_version', 'is_online'], $select: ['id', 'supervisor_version', 'is_online'],
}); });
id = device.id;
version = device.supervisor_version; const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const [whichProxytunnel, username, proxyUrl] = await Promise.all([ const [whichProxytunnel, username, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined, useProxy ? which('proxytunnel', false) : undefined,
@ -207,20 +202,13 @@ export default class NoteCmd extends Command {
const proxyCommand = useProxy ? getSshProxyCommand() : undefined; const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
if (username == null) { // At this point, we have a long uuid of a device
const { ExpectedError } = await import('../errors');
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 // that we know exists and is accessible
let containerId: string | undefined; let containerId: string | undefined;
if (params.service != null) { if (params.service != null) {
containerId = await this.getContainerId( containerId = await this.getContainerId(
sdk, sdk,
uuid, deviceUuid,
params.service, params.service,
{ {
port: options.port, port: options.port,
@ -228,20 +216,20 @@ export default class NoteCmd extends Command {
proxyUrl: proxyUrl || '', proxyUrl: proxyUrl || '',
username: username!, username: username!,
}, },
version, supervisorVersion,
id, deviceId,
); );
} }
let accessCommand: string; let accessCommand: string;
if (containerId != null) { if (containerId != null) {
accessCommand = `enter ${uuid} ${containerId}`; accessCommand = `enter ${deviceUuid} ${containerId}`;
} else { } else {
accessCommand = `host ${uuid}`; accessCommand = `host ${deviceUuid}`;
} }
const command = this.generateVpnSshCommand({ const command = this.generateVpnSshCommand({
uuid, uuid: deviceUuid,
command: accessCommand, command: accessCommand,
verbose: options.verbose, verbose: options.verbose,
port: options.port, port: options.port,

View File

@ -24,11 +24,7 @@ import {
} from '../errors'; } from '../errors';
import * as cf from '../utils/common-flags'; import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy'; import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { getOnlineTargetUuid } from '../utils/patterns'; import type { Server, Socket } from 'net';
import * as _ from 'lodash';
import { tunnelConnectionToDevice } from '../utils/tunnel';
import { createServer, Server, Socket } from 'net';
import { IArg } from '@oclif/parser/lib/args';
interface FlagsDef { interface FlagsDef {
port: string[]; port: string[];
@ -73,10 +69,10 @@ export default class TunnelCmd extends Command {
'$ balena tunnel myApp -p 8080:3000 -p 8081:9000', '$ balena tunnel myApp -p 8080:3000 -p 8081:9000',
]; ];
public static args: Array<IArg<any>> = [ public static args = [
{ {
name: 'deviceOrApplication', name: 'deviceOrApplication',
description: 'device uuid or application name/id', description: 'device uuid or application name/slug/id',
required: true, required: true,
}, },
]; ];
@ -101,8 +97,7 @@ export default class TunnelCmd extends Command {
TunnelCmd, TunnelCmd,
); );
const Logger = await import('../utils/logger'); const logger = await Command.getLogger();
const logger = Logger.getLogger();
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const logConnection = ( const logConnection = (
@ -127,23 +122,30 @@ export default class TunnelCmd extends Command {
throw new NoPortsDefinedError(); throw new NoPortsDefinedError();
} }
const uuid = await getOnlineTargetUuid(sdk, params.deviceOrApplication); // Ascertain device uuid
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const uuid = await getOnlineTargetDeviceUuid(
sdk,
params.deviceOrApplication,
);
const device = await sdk.models.device.get(uuid); const device = await sdk.models.device.get(uuid);
logger.logInfo(`Opening a tunnel to ${device.uuid}...`); logger.logInfo(`Opening a tunnel to ${device.uuid}...`);
const _ = await import('lodash');
const localListeners = _.chain(options.port) const localListeners = _.chain(options.port)
.map((mapping) => { .map((mapping) => {
return this.parsePortMapping(mapping); return this.parsePortMapping(mapping);
}) })
.map(async ({ localPort, localAddress, remotePort }) => { .map(async ({ localPort, localAddress, remotePort }) => {
try { try {
const { tunnelConnectionToDevice } = await import('../utils/tunnel');
const handler = await tunnelConnectionToDevice( const handler = await tunnelConnectionToDevice(
device.uuid, device.uuid,
remotePort, remotePort,
sdk, sdk,
); );
const { createServer } = await import('net');
const server = createServer(async (client: Socket) => { const server = createServer(async (client: Socket) => {
try { try {
await handler(client); await handler(client);

View File

@ -26,7 +26,8 @@ import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
import validation = require('./validation'); import validation = require('./validation');
import { delay } from './helpers'; import { delay } from './helpers';
import { isV13 } from './version'; import { isV13 } from './version';
import { Organization } from 'balena-sdk'; import type { Application, Device, Organization } from 'balena-sdk';
import { getApplication } from './sdk';
export function authenticate(options: {}): Promise<void> { export function authenticate(options: {}): Promise<void> {
const balena = getBalenaSdk(); const balena = getBalenaSdk();
@ -329,99 +330,94 @@ export function inferOrSelectDevice(preferredUuid: string) {
}); });
} }
async function getApplicationByIdOrName( /*
sdk: BalenaSdk.BalenaSDK, * Given applicationOrDevice, which may be
idOrName: string, * - an application name
) { * - an application slug
if (validation.looksLikeInteger(idOrName)) { * - an application id (integer)
try { * - a device uuid
return await sdk.models.application.get(Number(idOrName)); * Either:
} catch (error) { * - in case of device uuid, return uuid of device after verifying that it exists and is online.
const { BalenaApplicationNotFound } = await import('balena-errors'); * - in case of application, return uuid of device user selects from list of online devices.
if (!instanceOf(error, BalenaApplicationNotFound)) { *
throw error; * TODO: Modify this when app IDs dropped.
} */
} export async function getOnlineTargetDeviceUuid(
}
return await sdk.models.application.get(idOrName);
}
export async function getOnlineTargetUuid(
sdk: BalenaSdk.BalenaSDK, sdk: BalenaSdk.BalenaSDK,
applicationOrDevice: string, applicationOrDevice: string,
) { ) {
// applicationOrDevice can be: const logger = (await import('../utils/logger')).getLogger();
// * an application name
// * an application ID (integer)
// * a device uuid
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const appTest = validation.validateApplicationName(applicationOrDevice);
const uuidTest = validation.validateUuid(applicationOrDevice);
if (!appTest && !uuidTest) { // If looks like UUID, probably device
throw new ExpectedError( if (validation.validateUuid(applicationOrDevice)) {
`Device or application not found: ${applicationOrDevice}`, let device: Device;
);
}
// if we have a definite device UUID...
if (uuidTest && !appTest) {
logger.logDebug(
`Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
);
return (
await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})
).uuid;
}
// otherwise, it may be a device OR an application...
try { try {
logger.logDebug( logger.logDebug(
`Fetching application by ID or name ${applicationOrDevice} (${typeof applicationOrDevice})`, `Trying to fetch device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
); );
const app = await getApplicationByIdOrName(sdk, applicationOrDevice); device = await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid', 'is_online'],
});
if (!device.is_online) {
throw new ExpectedError(
`Device with UUID ${applicationOrDevice} is offline`,
);
}
return device.uuid;
} catch (err) {
const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(err, BalenaDeviceNotFound)) {
logger.logDebug(`Device with UUID ${applicationOrDevice} not found`);
// Now try app
} else {
throw err;
}
}
}
// Not a device UUID, try app
let app: Application;
try {
logger.logDebug(
`Trying to fetch application by name/slug/ID: ${applicationOrDevice}`,
);
app = await getApplication(sdk, applicationOrDevice);
} catch (err) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (instanceOf(err, BalenaApplicationNotFound)) {
throw new ExpectedError(
`Application or Device not found: ${applicationOrDevice}`,
);
} else {
throw err;
}
}
// App found, load its devices
const devices = await sdk.models.device.getAllByApplication(app.id, { const devices = await sdk.models.device.getAllByApplication(app.id, {
$select: ['device_name', 'uuid'],
$filter: { is_online: true }, $filter: { is_online: true },
}); });
// Throw if no devices online
if (_.isEmpty(devices)) { if (_.isEmpty(devices)) {
throw new ExpectedError('No accessible devices are online'); throw new ExpectedError(
`Application ${app.slug} found, but has no devices online.`,
);
} }
return await getCliForm().ask({ // Ask user to select from online devices for application
message: 'Select a device', return getCliForm().ask({
message: `Select a device on application ${app.slug}`,
type: 'list', type: 'list',
default: devices[0].uuid, default: devices[0].uuid,
choices: _.map(devices, (device) => ({ choices: _.map(devices, (device) => ({
name: `${device.device_name || 'Untitled'} (${device.uuid.slice( name: `${device.device_name || 'Untitled'} (${device.uuid.slice(0, 7)})`,
0,
7,
)})`,
value: device.uuid, value: device.uuid,
})), })),
}); });
} catch (err) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (!instanceOf(err, BalenaApplicationNotFound)) {
throw err;
}
logger.logDebug(`Application not found`);
}
// it wasn't an application, maybe it's a device...
logger.logDebug(
`Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
);
return (
await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})
).uuid;
} }
export function selectFromList<T>( export function selectFromList<T>(

View File

@ -162,6 +162,7 @@ function sshErrorMessage(exitSignal?: string, exitCode?: number) {
msg.push(` msg.push(`
Are the SSH keys correctly configured in balenaCloud? See: Are the SSH keys correctly configured in balenaCloud? See:
https://www.balena.io/docs/learn/manage/ssh-access/#add-an-ssh-key-to-balenacloud`); https://www.balena.io/docs/learn/manage/ssh-access/#add-an-ssh-key-to-balenacloud`);
msg.push('Are you accidentally using `sudo`?');
} }
} }
return msg.join('\n'); return msg.join('\n');

View File

@ -185,6 +185,7 @@ export class BalenaAPIMock extends NockMock {
public expectGetDevice(opts: { public expectGetDevice(opts: {
fullUUID: string; fullUUID: string;
inaccessibleApp?: boolean; inaccessibleApp?: boolean;
isOnline?: boolean;
optional?: boolean; optional?: boolean;
persist?: boolean; persist?: boolean;
}) { }) {
@ -194,6 +195,7 @@ export class BalenaAPIMock extends NockMock {
{ {
id, id,
uuid: opts.fullUUID, uuid: opts.fullUUID,
is_online: opts.isOnline,
belongs_to__application: opts.inaccessibleApp belongs_to__application: opts.inaccessibleApp
? [] ? []
: [{ app_name: 'test' }], : [{ app_name: 'test' }],

View File

@ -66,11 +66,11 @@ describe('balena ssh', function () {
itSS('should succeed (mocked, device UUID)', async () => { itSS('should succeed (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234'; const deviceUUID = 'abc1234';
api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true }); api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
api.expectGetDevice({ fullUUID: deviceUUID });
mockedExitCode = 0; mockedExitCode = 0;
const { err, out } = await runCommand(`ssh ${deviceUUID}`); const { err, out } = await runCommand(`ssh ${deviceUUID}`);
expect(err).to.be.empty; expect(err).to.be.empty;
expect(out).to.be.empty; expect(out).to.be.empty;
}); });
@ -90,8 +90,7 @@ describe('balena ssh', function () {
'Warning: ssh process exited with non-zero code "255"', 'Warning: ssh process exited with non-zero code "255"',
]; ];
api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true }); api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
api.expectGetDevice({ fullUUID: deviceUUID });
mockedExitCode = 255; mockedExitCode = 255;
const { err, out } = await runCommand(`ssh ${deviceUUID}`); const { err, out } = await runCommand(`ssh ${deviceUUID}`);
@ -114,6 +113,19 @@ 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 fail if device not online (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234';
const expectedErrLines = ['Device with UUID abc1234 is offline'];
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: false });
mockedExitCode = 0;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
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 */