mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-01-17 18:30:02 +00:00
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:
parent
f128eaf389
commit
9d2884aab7
@ -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 <deviceOrApplication>
|
||||
|
||||
@ -1863,7 +1863,7 @@ Examples:
|
||||
|
||||
#### DEVICEORAPPLICATION
|
||||
|
||||
device uuid or application name/id
|
||||
device uuid or application name/slug/id
|
||||
|
||||
### Options
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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>(
|
||||
|
@ -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');
|
||||
|
@ -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' }],
|
||||
|
@ -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 */
|
||||
|
Loading…
Reference in New Issue
Block a user