Convert command tunnel to oclif, and fix port map spec issues.

Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-06-29 14:45:32 +02:00
parent 35200697de
commit 9697081d73
9 changed files with 285 additions and 249 deletions

View File

@ -108,7 +108,7 @@ const capitanoDoc = {
files: [
'build/actions-oclif/scan.js',
'build/actions-oclif/ssh.js',
'build/actions/tunnel.js',
'build/actions-oclif/tunnel.js',
],
},
{

View File

@ -214,7 +214,7 @@ Users are encouraged to regularly update the balena CLI to the latest version.
- [scan](#scan)
- [ssh &#60;applicationordevice&#62; [servicename]](#ssh-applicationordevice-servicename)
- [tunnel &#60;deviceOrApplication&#62;](#tunnel-deviceorapplication)
- [tunnel &#60;deviceorapplication&#62;](#tunnel-deviceorapplication)
- Notes
@ -1438,30 +1438,41 @@ Use this command to open local ports which tunnel to listening ports on your bal
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
device running a web server listening on port 3000.
Port mappings are specified in the format: <remotePort>[:[localIP:]localPort]
localIP defaults to 'localhost', and localPort defaults to the specified remotePort value.
You can tunnel multiple ports at any given time.
Note: Port mappings must come after the deviceOrApplication parameter, as per examples.
Examples:
# map remote port 22222 to localhost:22222
$ balena tunnel abcde12345 -p 22222
$ balena tunnel myApp -p 22222
# map remote port 22222 to localhost:222
$ balena tunnel abcde12345 -p 22222:222
$ balena tunnel 2ead211 -p 22222:222
# map remote port 22222 to any address on your host machine, port 22222
$ balena tunnel abcde12345 -p 22222:0.0.0.0
$ balena tunnel 1546690 -p 22222:0.0.0.0
# map remote port 22222 to any address on your host machine, port 222
$ balena tunnel abcde12345 -p 22222:0.0.0.0:222
$ balena tunnel myApp -p 22222:0.0.0.0:222
# multiple port tunnels can be specified at any one time
$ balena tunnel abcde12345 -p 8080:3000 -p 8081:9000
$ balena tunnel myApp -p 8080:3000 -p 8081:9000
### Arguments
#### DEVICEORAPPLICATION
device uuid or application name/id
### Options
#### --port, -p &#60;port&#62;
#### -p, --port PORT
The mapping of remote to local ports.
port mapping in the format <remotePort>[:[localIP:]localPort]
# Notes

247
lib/actions-oclif/tunnel.ts Normal file
View File

@ -0,0 +1,247 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* 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 { flags } from '@oclif/command';
import Command from '../command';
import {
NoPortsDefinedError,
InvalidPortMappingError,
ExpectedError,
} 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 * as Bluebird from 'bluebird';
import { tryAsInteger } from '../utils/validation';
import { IArg } from '@oclif/parser/lib/args';
interface FlagsDef {
port: string[];
help: void;
}
interface ArgsDef {
deviceOrApplication: string;
}
export default class TunnelCmd extends Command {
public static description = stripIndent`
Tunnel local ports to your balenaOS device.
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
device running a web server listening on port 3000.
Port mappings are specified in the format: <remotePort>[:[localIP:]localPort]
localIP defaults to 'localhost', and localPort defaults to the specified remotePort value.
You can tunnel multiple ports at any given time.
Note: Port mappings must come after the deviceOrApplication parameter, as per examples.
`;
public static examples = [
'# map remote port 22222 to localhost:22222',
'$ balena tunnel myApp -p 22222',
'',
'# map remote port 22222 to localhost:222',
'$ balena tunnel 2ead211 -p 22222:222',
'',
'# map remote port 22222 to any address on your host machine, port 22222',
'$ balena tunnel 1546690 -p 22222:0.0.0.0',
'',
'# map remote port 22222 to any address on your host machine, port 222',
'$ balena tunnel myApp -p 22222:0.0.0.0:222',
'',
'# multiple port tunnels can be specified at any one time',
'$ balena tunnel myApp -p 8080:3000 -p 8081:9000',
];
public static args: Array<IArg<any>> = [
{
name: 'deviceOrApplication',
description: 'device uuid or application name/id',
parse: (x) => tryAsInteger(x),
required: true,
},
];
public static usage = 'tunnel <deviceOrApplication>';
public static flags: flags.Input<FlagsDef> = {
port: flags.string({
description:
'port mapping in the format <remotePort>[:[localIP:]localPort]',
char: 'p',
multiple: true,
}),
help: cf.help,
};
public static primary = true;
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
TunnelCmd,
);
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const sdk = getBalenaSdk();
const logConnection = (
fromHost: string,
fromPort: number,
localAddress: string,
localPort: number,
deviceAddress: string,
devicePort: number,
err?: Error,
) => {
const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`;
if (err) {
logger.logError(`${logMessage} :: ${err.message}`);
} else {
logger.logLogs(logMessage);
}
};
if (options.port === undefined) {
throw new NoPortsDefinedError();
}
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(options.port)
.map((mapping) => {
return this.parsePortMapping(mapping);
})
.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}`,
);
return true;
})
.catch((err: Error) => {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return false;
});
})
.value();
const results = await Promise.all(localListeners);
if (!results.includes(true)) {
throw new ExpectedError('No ports are valid for tunnelling');
}
logger.logInfo('Waiting for connections...');
}
/**
* Parse a port mapping specification string in the format:
* <remotePort>[:[localIP:]localPort]
* @param portMapping
*/
parsePortMapping(portMapping: string) {
const mappingElements = portMapping.split(':');
let localAddress = 'localhost';
// First element is always remotePort
const remotePort = parseInt(mappingElements[0], undefined);
let localPort = remotePort;
if (mappingElements.length === 2) {
// [1] could be localAddress or localPort
if (/^\d+$/.test(mappingElements[1])) {
localPort = parseInt(mappingElements[1], undefined);
} else {
localAddress = mappingElements[1];
}
} else if (mappingElements.length === 3) {
// [1] is localAddress, [2] is localPort
localAddress = mappingElements[1];
localPort = parseInt(mappingElements[2], undefined);
} else if (mappingElements.length > 3) {
throw new InvalidPortMappingError(portMapping);
}
// Validate results
if (!this.isValidPort(remotePort) || !this.isValidPort(localPort)) {
throw new InvalidPortMappingError(portMapping);
}
return { remotePort, localAddress, localPort };
}
isValidPort(port: number) {
const MAX_PORT_VALUE = Math.pow(2, 16) - 1;
return port > 0 && port <= MAX_PORT_VALUE;
}
}

View File

@ -21,7 +21,6 @@ export * as local from './local';
export * as logs from './logs';
export * as os from './os';
export * as push from './push';
export * as tunnel from './tunnel';
export * as util from './util';
export { build } from './build';

View File

@ -1,231 +0,0 @@
/*
Copyright 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 type { CommandDefinition } from 'capitano';
import * as _ from 'lodash';
import { createServer, Server, Socket } from 'net';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { getOnlineTargetUuid } from '../utils/patterns';
import { tunnelConnectionToDevice } from '../utils/tunnel';
import { ExpectedError } from '../errors';
interface Args {
deviceOrApplication: string;
// when Capitano converts a positional parameter (but not an option)
// to a number, the original value is preserved with the _raw suffix
deviceOrApplication_raw: string;
}
interface Options {
port: string | string[];
}
class InvalidPortMappingError extends Error {
constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`);
}
}
class NoPortsDefinedError extends Error {
constructor() {
super('No ports have been provided.');
}
}
const isValidPort = (port: number) => {
const MAX_PORT_VALUE = Math.pow(2, 16) - 1;
return port > 0 && port <= MAX_PORT_VALUE;
};
export const tunnel: CommandDefinition<Args, Options> = {
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.
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
device running a web server listening on port 3000.
You can tunnel multiple ports at any given time.
Examples:
# map remote port 22222 to localhost:22222
$ balena tunnel abcde12345 -p 22222
# map remote port 22222 to localhost:222
$ balena tunnel abcde12345 -p 22222:222
# map remote port 22222 to any address on your host machine, port 22222
$ balena tunnel abcde12345 -p 22222:0.0.0.0
# map remote port 22222 to any address on your host machine, port 222
$ balena tunnel abcde12345 -p 22222:0.0.0.0:222
# multiple port tunnels can be specified at any one time
$ balena tunnel abcde12345 -p 8080:3000 -p 8081:9000
`,
options: [
{
signature: 'port',
parameter: 'port',
alias: 'p',
description: 'The mapping of remote to local ports.',
},
],
primary: true,
action: async (params, options) => {
const deviceOrApplication =
params.deviceOrApplication_raw || params.deviceOrApplication;
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const sdk = getBalenaSdk();
const logConnection = (
fromHost: string,
fromPort: number,
localAddress: string,
localPort: number,
deviceAddress: string,
devicePort: number,
err?: Error,
) => {
const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`;
if (err) {
logger.logError(`${logMessage} :: ${err.message}`);
} else {
logger.logLogs(logMessage);
}
};
if (options.port === undefined) {
throw new NoPortsDefinedError();
}
const ports =
typeof options.port !== 'string' && Array.isArray(options.port)
? (options.port as string[])
: [options.port as string];
const uuid = await getOnlineTargetUuid(sdk, 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);
}
// 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 Promise<Server>((resolve, reject) => {
server.on('error', reject);
server.listen(localPort, localAddress, () => {
resolve(server);
});
}),
)
.then(() => {
logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${device.uuid}:${remotePort}`,
);
return true;
})
.catch((err: Error) => {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return false;
});
})
.value();
const results = await Promise.all(localListeners);
if (!results.includes(true)) {
throw new ExpectedError('No ports are valid for tunnelling');
}
logger.logInfo('Waiting for connections...');
},
};

View File

@ -66,9 +66,6 @@ capitano.command(actions.config.generate);
// ---------- Logs Module ----------
capitano.command(actions.logs.logs);
// ---------- Tunnel Module ----------
capitano.command(actions.tunnel.tunnel);
// ---------- Preload Module ----------
capitano.command(actions.preload);

View File

@ -27,6 +27,18 @@ export class NotLoggedInError extends ExpectedError {}
export class InsufficientPrivilegesError extends ExpectedError {}
export class InvalidPortMappingError extends ExpectedError {
constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`);
}
}
export class NoPortsDefinedError extends ExpectedError {
constructor() {
super('No ports have been provided.');
}
}
/**
* instanceOf is a more reliable implementation of the plain `instanceof`
* typescript operator, for use with TypedError errors when the error

View File

@ -175,6 +175,7 @@ export const convertedCommands = [
'tags',
'tag:rm',
'tag:set',
'tunnel',
'version',
'whoami',
];

View File

@ -15,7 +15,7 @@ Primary commands:
app <name> display information about a single application
devices list all devices
device <uuid> show info about a single device
tunnel <deviceOrApplication> Tunnel local ports to your balenaOS device
tunnel <deviceorapplication> tunnel local ports to your balenaOS device
preload <image> preload an app on a disk image (or Edison zip archive)
build [source] Build a single image or a multicontainer project locally
deploy <appName> [image] Deploy a single image or a multicontainer project to a balena application