diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 6854bf6d..30732ea9 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -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', ], }, { diff --git a/doc/cli.markdown b/doc/cli.markdown index b104a53a..dbda59fb 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -214,7 +214,7 @@ Users are encouraged to regularly update the balena CLI to the latest version. - [scan](#scan) - [ssh <applicationordevice> [servicename]](#ssh-applicationordevice-servicename) - - [tunnel <deviceOrApplication>](#tunnel-deviceorapplication) + - [tunnel <deviceorapplication>](#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: [:[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 <port> +#### -p, --port PORT -The mapping of remote to local ports. +port mapping in the format [:[localIP:]localPort] # Notes diff --git a/lib/actions-oclif/tunnel.ts b/lib/actions-oclif/tunnel.ts new file mode 100644 index 00000000..f62a3037 --- /dev/null +++ b/lib/actions-oclif/tunnel.ts @@ -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: [:[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> = [ + { + name: 'deviceOrApplication', + description: 'device uuid or application name/id', + parse: (x) => tryAsInteger(x), + required: true, + }, + ]; + + public static usage = 'tunnel '; + + public static flags: flags.Input = { + port: flags.string({ + description: + 'port mapping in the format [:[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( + 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((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: + * [:[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; + } +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index fff9c849..f417d4cd 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -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'; diff --git a/lib/actions/tunnel.ts b/lib/actions/tunnel.ts deleted file mode 100644 index 11aff425..00000000 --- a/lib/actions/tunnel.ts +++ /dev/null @@ -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 = { - signature: 'tunnel ', - 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((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...'); - }, -}; diff --git a/lib/app-capitano.js b/lib/app-capitano.js index f0fa7873..3cf57cc0 100644 --- a/lib/app-capitano.js +++ b/lib/app-capitano.js @@ -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); diff --git a/lib/errors.ts b/lib/errors.ts index 8fc5479a..6bac167c 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -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 diff --git a/lib/preparser.ts b/lib/preparser.ts index 703fb282..1a45b68c 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -175,6 +175,7 @@ export const convertedCommands = [ 'tags', 'tag:rm', 'tag:set', + 'tunnel', 'version', 'whoami', ]; diff --git a/tests/commands/help.spec.ts b/tests/commands/help.spec.ts index dc78e019..f31c9b16 100644 --- a/tests/commands/help.spec.ts +++ b/tests/commands/help.spec.ts @@ -15,7 +15,7 @@ Primary commands: app display information about a single application devices list all devices device show info about a single device - tunnel Tunnel local ports to your balenaOS device + tunnel tunnel local ports to your balenaOS device preload preload an app on a disk image (or Edison zip archive) build [source] Build a single image or a multicontainer project locally deploy [image] Deploy a single image or a multicontainer project to a balena application