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,
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
host OS or service container of the chosen device.
@ -1803,7 +1803,7 @@ Examples:
#### APPLICATIONORDEVICE
application name, device uuid, or address of local device
application name/slug/id, device uuid, or address of local device
#### SERVICE
@ -1818,15 +1818,15 @@ hostname. Otherwise, port number for the balenaCloud gateway (default 22).
#### -t, --tty
Force pseudo-terminal allocation (bypass TTY autodetection for stdin)
force pseudo-terminal allocation (bypass TTY autodetection for stdin)
#### -v, --verbose
Increase verbosity
increase verbosity
#### --noproxy
Bypass global proxy configuration for the ssh connection
bypass global proxy configuration for the ssh connection
## tunnel &#60;deviceOrApplication&#62;
@ -1863,7 +1863,7 @@ Examples:
#### DEVICEORAPPLICATION
device uuid or application name/id
device uuid or application name/slug/id
### Options

View File

@ -19,11 +19,7 @@ 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 { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
import * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
@ -39,14 +35,14 @@ interface ArgsDef {
service?: string;
}
export default class NoteCmd extends Command {
export default class SshCmd 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
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
host OS or service container of the chosen device.
@ -81,7 +77,8 @@ export default class NoteCmd extends Command {
public static args = [
{
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,
},
{
@ -104,17 +101,17 @@ export default class NoteCmd extends Command {
tty: flags.boolean({
default: false,
description:
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
'force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
char: 't',
}),
verbose: flags.boolean({
default: false,
description: 'Increase verbosity',
description: 'increase verbosity',
char: 'v',
}),
noproxy: flags.boolean({
default: false,
description: 'Bypass global proxy configuration for the ssh connection',
description: 'bypass global proxy configuration for the ssh connection',
}),
help: cf.help,
};
@ -123,14 +120,11 @@ export default class NoteCmd extends Command {
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
NoteCmd,
SshCmd,
);
// if we're doing a direct SSH connection locally...
if (
validateDotLocalUrl(params.applicationOrDevice) ||
validateIPAddress(params.applicationOrDevice)
) {
// Local connection
if (validateLocalHostnameOrIp(params.applicationOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
address: params.applicationOrDevice,
@ -141,26 +135,27 @@ export default class NoteCmd extends Command {
});
}
// Remote connection
const { getProxyConfig, which } = await import('../utils/helpers');
const { checkLoggedIn, getOnlineTargetUuid } = await import(
'../utils/patterns'
);
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 checkLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice);
let version: string | undefined;
let id: number | undefined;
await Command.checkLoggedIn();
const deviceUuid = await getOnlineTargetDeviceUuid(
sdk,
params.applicationOrDevice,
);
const device = await sdk.models.device.get(uuid, {
const device = await sdk.models.device.get(deviceUuid, {
$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([
useProxy ? which('proxytunnel', false) : undefined,
@ -207,20 +202,13 @@ export default class NoteCmd extends Command {
const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
if (username == null) {
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
// 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) {
containerId = await this.getContainerId(
sdk,
uuid,
deviceUuid,
params.service,
{
port: options.port,
@ -228,20 +216,20 @@ export default class NoteCmd extends Command {
proxyUrl: proxyUrl || '',
username: username!,
},
version,
id,
supervisorVersion,
deviceId,
);
}
let accessCommand: string;
if (containerId != null) {
accessCommand = `enter ${uuid} ${containerId}`;
accessCommand = `enter ${deviceUuid} ${containerId}`;
} else {
accessCommand = `host ${uuid}`;
accessCommand = `host ${deviceUuid}`;
}
const command = this.generateVpnSshCommand({
uuid,
uuid: deviceUuid,
command: accessCommand,
verbose: options.verbose,
port: options.port,

View File

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

View File

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

View File

@ -162,6 +162,7 @@ function sshErrorMessage(exitSignal?: string, exitCode?: number) {
msg.push(`
Are the SSH keys correctly configured in balenaCloud? See:
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');

View File

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

View File

@ -66,11 +66,11 @@ describe('balena ssh', function () {
itSS('should succeed (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234';
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true });
api.expectGetDevice({ fullUUID: deviceUUID });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
mockedExitCode = 0;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
expect(err).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"',
];
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true });
api.expectGetDevice({ fullUUID: deviceUUID });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
mockedExitCode = 255;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
@ -114,6 +113,19 @@ describe('balena ssh', function () {
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
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 */