diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 160f3d69..53f83eaa 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -41,3 +41,4 @@ module.exports = push: require('./push') join: require('./join') leave: require('./leave') + tunnel: require('./tunnel') diff --git a/lib/actions/tunnel.ts b/lib/actions/tunnel.ts new file mode 100644 index 00000000..211880f6 --- /dev/null +++ b/lib/actions/tunnel.ts @@ -0,0 +1,152 @@ +/* +Copyright 2016-2017 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 * as Bluebird from 'bluebird'; +import * as _ from 'lodash'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; +import { isArray } from 'util'; +import { createServer } from 'net'; +import { tunnelConnectionToDevice } from '../utils/tunnel'; + +interface Args { + uuid: string; +} + +interface Options { + port: string | string[]; +} + +class InvalidPortMappingError extends Error { + constructor(mapping: string) { + super(`'${mapping}' is not a valid port mapping.`); + } +} + +export const tunnel: CommandDefinition = { + signature: 'tunnel [uuid]', + 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, + + async action(params, options, done) { + const Logger = await import('../utils/logger'); + const logger = new Logger(); + const balena = await import('balena-sdk'); + const sdk = balena.fromSharedOptions(); + return Bluebird.try(() => { + logger.logInfo(`Tunnel to ${params.uuid}`); + + const ports = + typeof options.port !== 'string' && isArray(options.port) + ? (options.port as string[]) + : [options.port as string]; + + 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 + let [, remotePort, localHost, localPort] = regexResult; + + // default bind to localhost + if (localHost == undefined) { + localHost = 'localhost'; + } + + // default use same port number locally as remote + if (localPort == undefined) { + localPort = remotePort; + } + + return { + localPort: parseInt(localPort), + localHost, + remotePort: parseInt(remotePort), + }; + }) + .map(({ localPort, localHost, remotePort }) => { + return tunnelConnectionToDevice(params.uuid, remotePort, sdk) + .then(handler => { + logger.logInfo( + `- tunnelling ${localHost}:${localPort} to remote:${remotePort}`, + ); + return createServer(handler) + .on('connection', connection => { + logger.logLogs( + `[${new Date().toISOString()}] => ${ + connection.remotePort + } => ${ + connection.localAddress + }:${localPort} => ${remotePort}`, + ); + }) + .on('error', err => { + console.error(err); + throw err; + }) + .listen(localPort, localHost); + }) + .catch((err: Error) => { + console.error(err); + }); + }) + .value(); + + return Bluebird.all(localListeners).then(() => { + logger.logInfo('Waiting for connections...'); + }); + }).nodeify(done); + }, +}; diff --git a/lib/app.coffee b/lib/app.coffee index 3dca1e67..983e3daf 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -191,6 +191,9 @@ capitano.command(actions.logs) # ---------- Sync Module ---------- capitano.command(actions.sync) +# ---------- Tunnel Module ---------- +capitano.command(actions.tunnel.tunnel) + # ---------- Preload Module ---------- capitano.command(actions.preload) diff --git a/lib/utils/tunnel.ts b/lib/utils/tunnel.ts new file mode 100644 index 00000000..beab994e --- /dev/null +++ b/lib/utils/tunnel.ts @@ -0,0 +1,108 @@ +/* +Copyright 2016-2017 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 * as Bluebird from 'bluebird'; +import { BalenaSDK } from 'balena-sdk'; +import { Socket } from 'net'; + +class UnableToConnectError extends Error {} + +export const tunnelConnectionToDevice = ( + uuid: string, + port: number, + sdk: BalenaSDK, +) => { + return Bluebird.props({ + vpnUrl: sdk.settings.get('vpnUrl'), + whoami: sdk.auth.whoami(), + token: sdk.auth.getToken(), + }).then(({ vpnUrl, whoami, token }) => { + const auth = { + user: whoami || 'root', + password: token, + }; + + return (client: Socket): void => { + openPortThroughProxy(vpnUrl, 3128, auth, uuid, port) + .then(remote => { + client.pipe(remote); + remote.pipe(client); + + remote.on('error', err => { + console.error('Remote: ' + err); + client.end(); + }); + + client.on('error', err => { + console.error('Client: ' + err); + remote.end(); + }); + + remote.on('close', () => { + client.end(); + }); + + client.on('close', () => { + remote.end(); + }); + }) + .tapCatch(err => { + console.error(err); + client.end(); + }); + }; + }); +}; + +const openPortThroughProxy = ( + proxyServer: string, + proxyPort: number, + proxyAuth: { user: string; password: string } | null, + deviceUuid: string, + devicePort: number, +) => { + const httpHeaders = [`CONNECT ${deviceUuid}.balena:${devicePort} HTTP/1.0`]; + + if (proxyAuth !== null) { + const credentials = Buffer.from( + `${proxyAuth.user}:${proxyAuth.password}`, + ).toString('base64'); + + httpHeaders.push(`Proxy-Authorization: Basic ${credentials}`); + } + + return new Bluebird.Promise((resolve, reject) => { + const proxyTunnel = new Socket(); + proxyTunnel.connect(proxyPort, proxyServer, () => { + const proxyConnectionHandler = (data: Buffer) => { + proxyTunnel.removeListener('data', proxyConnectionHandler); + const [httpStatus] = data.toString('utf8').split('\r\n'); + const [, httpStatusCode, ...httpMessage] = httpStatus.split(' '); + + if (parseInt(httpStatusCode) === 200) { + resolve(proxyTunnel); + } else { + console.error( + `Connection failed. ${httpStatusCode} ${httpMessage.join(' ')}`, + ); + reject(new UnableToConnectError()); + } + }; + + proxyTunnel.on('data', proxyConnectionHandler); + proxyTunnel.write(httpHeaders.join('\r\n').concat('\r\n\r\n')); + }); + }); +};