fix: Add single code path to get full, online-only device UUIDs

Both the tunnel and SSH commands require a full UUID for an online
device. A single code path was added to provide this, taking either
an application name or a partial UUID as a search parameter.

In the event of an application name being provided, a device select
form is presented to the user to pick from the online devices at that
time.

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
This commit is contained in:
Rich Bayliss 2019-06-06 11:24:44 +01:00
parent 2bbdfda92e
commit 5d137f3c20
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
3 changed files with 245 additions and 345 deletions

View File

@ -17,84 +17,8 @@ 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;
}
import { BalenaDeviceNotFound } from 'balena-errors';
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
async function getContainerId(
sdk: BalenaSdk.BalenaSDK,
@ -297,17 +221,16 @@ export const ssh: CommandDefinition<
},
],
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 { exitIfNotLoggedIn } = await import('../utils/patterns');
const { exitWithExpectedError, selectFromList } = await import(
'../utils/patterns'
);
const {
exitIfNotLoggedIn,
exitWithExpectedError,
getOnlineTargetUuid,
} = await import('../utils/patterns');
const sdk = BalenaSdk.fromSharedOptions();
const verbose = options.verbose === true;
@ -316,7 +239,45 @@ export const ssh: CommandDefinition<
const useProxy = !!proxyConfig && !options.noProxy;
const port = options.port != null ? parseInt(options.port, 10) : undefined;
const getSshProxyCommand = (hasTunnelBin: boolean) => {
// if we're doing a direct SSH connection locally...
if (
validateDotLocalUrl(params.applicationOrDevice) ||
validateIPAddress(params.applicationOrDevice)
) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
address: params.applicationOrDevice,
port,
verbose,
service: params.serviceName,
});
}
// this will be a tunnelled SSH connection...
exitIfNotLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice);
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}`);
}
}
const [hasTunnelBin, username, proxyUrl] = await Promise.all([
useProxy ? await hasbin('proxytunnel') : undefined,
sdk.auth.whoami(),
sdk.settings.get('proxyUrl'),
]);
const getSshProxyCommand = () => {
if (!useProxy) {
return '';
}
@ -346,159 +307,57 @@ export const ssh: CommandDefinition<
};
}
const proxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
return `-o ${bash.args({ ProxyCommand: proxyCommand }, '', '=')}`;
const ProxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
return `-o ${bash.args({ ProxyCommand }, '', '=')}`;
};
// Detect what type of SSH we're doing
const maybeParamChecks = await getSSHTarget(
sdk,
params.applicationOrDevice,
);
if (maybeParamChecks == null) {
const proxyCommand = getSshProxyCommand();
if (username == null) {
exitWithExpectedError(
new Error(stripIndent`
Could not parse SSH target.
You can provide an application name, IP address or .local address`),
`Opening an SSH connection to a remote device requires you to be logged in.`,
);
}
const paramChecks = maybeParamChecks!;
switch (paramChecks.target) {
case SSHTarget.APPLICATION:
exitIfNotLoggedIn();
// 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;
} catch (e) {
if (e instanceof BalenaApplicationNotFound) {
exitWithExpectedError(
`Could not find an application named ${
params.applicationOrDevice
}`,
);
}
throw e;
}
case SSHTarget.DEVICE:
exitIfNotLoggedIn();
// 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,
// 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!,
});
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;
},
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',
});
},
};

View File

@ -20,24 +20,17 @@ import * as _ from 'lodash';
import { createServer, Server, Socket } from 'net';
import { isArray } from 'util';
import { inferOrSelectDevice } from '../utils/patterns';
import { getOnlineTargetUuid } from '../utils/patterns';
import { tunnelConnectionToDevice } from '../utils/tunnel';
interface Args {
uuid: string;
deviceOrApplication: string;
}
interface Options {
port: string | string[];
}
class DeviceIsOfflineError extends Error {
public uuid: string;
constructor(uuid: string) {
super(`Device '${uuid}' is offline`);
this.uuid = uuid;
}
}
class InvalidPortMappingError extends Error {
constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`);
@ -56,7 +49,7 @@ const isValidPort = (port: number) => {
};
export const tunnel: CommandDefinition<Args, Options> = {
signature: 'tunnel <uuid>',
signature: 'tunnel <deviceOrApplication>',
description: 'Tunnel local ports to your balenaOS device',
help: stripIndent`
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
@ -94,7 +87,7 @@ export const tunnel: CommandDefinition<Args, Options> = {
primary: true,
async action(params, options, done) {
action: async (params, options) => {
const Logger = await import('../utils/logger');
const logger = new Logger();
const balena = await import('balena-sdk');
@ -118,8 +111,6 @@ export const tunnel: CommandDefinition<Args, Options> = {
}
};
logger.logInfo(`Tunnel to ${params.uuid}`);
if (options.port === undefined) {
throw new NoPortsDefinedError();
}
@ -129,117 +120,111 @@ export const tunnel: CommandDefinition<Args, Options> = {
? (options.port as string[])
: [options.port as string];
return inferOrSelectDevice(params.uuid)
.then(sdk.models.device.get)
.then(device => {
if (!device.is_online) {
throw new DeviceIsOfflineError(device.uuid);
const uuid = await getOnlineTargetUuid(sdk, params.deviceOrApplication);
const device = await sdk.models.device.get(uuid);
logger.logInfo(`Opening a tunnel to ${device.uuid}...`);
const localListeners = _.chain(ports)
.map(mapping => {
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec(
mapping,
);
if (regexResult === null) {
throw new InvalidPortMappingError(mapping);
}
const localListeners = _.chain(ports)
.map(mapping => {
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec(
mapping,
// grab the groups
// tslint:disable-next-line:prefer-const
let [, remotePort, localAddress, localPort] = regexResult;
if (
!isValidPort(parseInt(localPort, undefined)) ||
!isValidPort(parseInt(remotePort, undefined))
) {
throw new InvalidPortMappingError(mapping);
}
// default bind to localAddress
if (localAddress == null) {
localAddress = 'localhost';
}
// default use same port number locally as remote
if (localPort == null) {
localPort = remotePort;
}
return {
localPort: parseInt(localPort, undefined),
localAddress,
remotePort: parseInt(remotePort, undefined),
};
})
.map(({ localPort, localAddress, remotePort }) => {
return tunnelConnectionToDevice(device.uuid, remotePort, sdk)
.then(handler =>
createServer((client: Socket) => {
return handler(client)
.then(() => {
logConnection(
client.remoteAddress || '',
client.remotePort || 0,
client.localAddress,
client.localPort,
device.vpn_address || '',
remotePort,
);
})
.catch(err =>
logConnection(
client.remoteAddress || '',
client.remotePort || 0,
client.localAddress,
client.localPort,
device.vpn_address || '',
remotePort,
err,
),
);
}),
)
.then(
server =>
new Bluebird.Promise<Server>((resolve, reject) => {
server.on('error', reject);
server.listen(localPort, localAddress, () => {
resolve(server);
});
}),
)
.then(() => {
logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}`,
);
if (regexResult === null) {
throw new InvalidPortMappingError(mapping);
}
// grab the groups
// tslint:disable-next-line:prefer-const
let [, remotePort, localAddress, localPort] = regexResult;
if (
!isValidPort(parseInt(localPort, undefined)) ||
!isValidPort(parseInt(remotePort, undefined))
) {
throw new InvalidPortMappingError(mapping);
}
// default bind to localAddress
if (localAddress == null) {
localAddress = 'localhost';
}
// default use same port number locally as remote
if (localPort == null) {
localPort = remotePort;
}
return {
localPort: parseInt(localPort, undefined),
localAddress,
remotePort: parseInt(remotePort, undefined),
};
return true;
})
.map(({ localPort, localAddress, remotePort }) => {
return tunnelConnectionToDevice(device.uuid, remotePort, sdk)
.then(handler =>
createServer((client: Socket) => {
return handler(client)
.then(() => {
logConnection(
client.remoteAddress || '',
client.remotePort || 0,
client.localAddress,
client.localPort,
device.vpn_address || '',
remotePort,
);
})
.catch(err =>
logConnection(
client.remoteAddress || '',
client.remotePort || 0,
client.localAddress,
client.localPort,
device.vpn_address || '',
remotePort,
err,
),
);
}),
)
.then(
server =>
new Bluebird.Promise<Server>((resolve, reject) => {
server.on('error', reject);
server.listen(localPort, localAddress, () => {
resolve(server);
});
}),
)
.then(() => {
logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}`,
);
.catch((err: Error) => {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return true;
})
.catch((err: Error) => {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return false;
});
})
.value();
return Bluebird.all(localListeners);
return false;
});
})
.then(results => {
if (!results.includes(true)) {
throw new Error('No ports are valid for tunnelling');
}
.value();
logger.logInfo('Waiting for connections...');
})
.nodeify(done);
const results = await Promise.all(localListeners);
if (!results.includes(true)) {
throw new Error('No ports are valid for tunnelling');
}
logger.logInfo('Waiting for connections...');
},
};

View File

@ -13,6 +13,7 @@ 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 { BalenaApplicationNotFound } from 'balena-errors';
import BalenaSdk = require('balena-sdk');
import Bluebird = require('bluebird');
import chalk from 'chalk';
@ -283,6 +284,61 @@ export function inferOrSelectDevice(preferredUuid: string) {
});
}
export async function getOnlineTargetUuid(
sdk: BalenaSdk.BalenaSDK,
applicationOrDevice: string,
) {
const appTest = validation.validateApplicationName(applicationOrDevice);
const uuidTest = validation.validateUuid(applicationOrDevice);
if (!appTest && !uuidTest) {
throw new Error(`Device or application not found: ${applicationOrDevice}`);
}
// if we have a definite device UUID...
if (uuidTest && !appTest) {
return (await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})).uuid;
}
// otherwise, it may be a device OR an application...
try {
const app = await sdk.models.application.get(applicationOrDevice);
const devices = await sdk.models.device.getAllByApplication(app.id, {
$filter: { is_online: true },
});
if (_.isEmpty(devices)) {
throw new Error('No accessible devices are online');
}
return await getForm().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,
})),
});
} catch (err) {
if (!(err instanceof BalenaApplicationNotFound)) {
throw err;
}
}
// it wasn't an application, maybe it's a device...
return (await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})).uuid;
}
export function selectFromList<T>(
message: string,
choices: Array<T & { name: string }>,