Merge pull request #1105 from balena-io/refactor-tunnel-command

tunnel: Refactor to improve log output
This commit is contained in:
Rich Bayliss 2019-02-20 21:52:18 +00:00 committed by GitHub
commit 8863132e8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 184 additions and 78 deletions

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2016-2017 Balena Copyright 2019 Balena
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,7 +18,7 @@ import * as _ from 'lodash';
import { CommandDefinition } from 'capitano'; import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags'; import { stripIndent } from 'common-tags';
import { isArray } from 'util'; import { isArray } from 'util';
import { createServer } from 'net'; import { Socket, Server, createServer } from 'net';
import { tunnelConnectionToDevice } from '../utils/tunnel'; import { tunnelConnectionToDevice } from '../utils/tunnel';
interface Args { interface Args {
@ -29,14 +29,32 @@ interface Options {
port: string | string[]; port: string | string[];
} }
class DeviceIsOfflineError extends Error {
uuid: string;
constructor(uuid: string) {
super(`Device '${uuid}' is offline`);
this.uuid = uuid;
}
}
class InvalidPortMappingError extends Error { class InvalidPortMappingError extends Error {
constructor(mapping: string) { constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`); 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> = { export const tunnel: CommandDefinition<Args, Options> = {
signature: 'tunnel [uuid]', signature: 'tunnel <uuid>',
description: 'Tunnel local ports to your balenaOS device', description: 'Tunnel local ports to your balenaOS device',
help: stripIndent` help: stripIndent`
Use this command to open local ports which tunnel to listening ports on your balenaOS device. Use this command to open local ports which tunnel to listening ports on your balenaOS device.
@ -79,74 +97,145 @@ export const tunnel: CommandDefinition<Args, Options> = {
const logger = new Logger(); const logger = new Logger();
const balena = await import('balena-sdk'); const balena = await import('balena-sdk');
const sdk = balena.fromSharedOptions(); const sdk = balena.fromSharedOptions();
return Bluebird.try(() => {
logger.logInfo(`Tunnel to ${params.uuid}`);
const ports = const logConnection = (
typeof options.port !== 'string' && isArray(options.port) fromHost: string,
? (options.port as string[]) fromPort: number,
: [options.port as string]; localAddress: string,
localPort: number,
deviceAddress: string,
devicePort: number,
err?: Error,
) => {
const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`;
const localListeners = _.chain(ports) if (err) {
.map(mapping => { logger.logError(`${logMessage} :: ${err.message}`);
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec( } else {
mapping, logger.logLogs(logMessage);
); }
};
if (regexResult === null) { logger.logInfo(`Tunnel to ${params.uuid}`);
throw new InvalidPortMappingError(mapping);
if (options.port === undefined) {
throw new NoPortsDefinedError();
}
const ports =
typeof options.port !== 'string' && isArray(options.port)
? (options.port as string[])
: [options.port as string];
return Bluebird.try(() =>
sdk.models.device
.get(params.uuid)
.then(device => {
if (!device.is_online) {
throw new DeviceIsOfflineError(params.uuid);
} }
// grab the groups const localListeners = _.chain(ports)
let [, remotePort, localHost, localPort] = regexResult; .map(mapping => {
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec(
// default bind to localhost mapping,
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(() => { if (regexResult === null) {
logger.logInfo('Waiting for connections...'); throw new InvalidPortMappingError(mapping);
}); }
}).nodeify(done);
// grab the groups
let [, remotePort, localAddress, localPort] = regexResult;
if (
!isValidPort(parseInt(localPort)) ||
!isValidPort(parseInt(remotePort))
) {
throw new InvalidPortMappingError(mapping);
}
// default bind to localAddress
if (localAddress == undefined) {
localAddress = 'localhost';
}
// default use same port number locally as remote
if (localPort == undefined) {
localPort = remotePort;
}
return {
localPort: parseInt(localPort),
localAddress,
remotePort: parseInt(remotePort),
};
})
.map(({ localPort, localAddress, remotePort }) => {
return tunnelConnectionToDevice(params.uuid, remotePort, sdk)
.then(handler =>
createServer((client: Socket) => {
return handler(client)
.then(() => {
logConnection(
client.remoteAddress,
client.remotePort,
client.localAddress,
client.localPort,
device.vpn_address || '',
remotePort,
);
})
.catch(err =>
logConnection(
client.remoteAddress,
client.remotePort,
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:${remotePort}`,
);
return true;
})
.catch((err: Error) => {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to device:${remotePort}, failed ${JSON.stringify(
err.message,
)}`,
);
return false;
});
})
.value();
return Bluebird.all(localListeners);
})
.then(results => {
if (!results.includes(true)) {
throw new Error('No ports are valid for tunnelling');
}
logger.logInfo('Waiting for connections...');
}),
).nodeify(done);
}, },
}; };

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2016-2017 Balena Copyright 2019 Balena
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,8 +16,26 @@ limitations under the License.
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import { BalenaSDK } from 'balena-sdk'; import { BalenaSDK } from 'balena-sdk';
import { Socket } from 'net'; import { Socket } from 'net';
import { TypedError } from 'typed-error';
class UnableToConnectError extends Error {} const PROXY_CONNECT_TIMEOUT_MS = 10000;
class UnableToConnectError extends TypedError {
status: string;
statusCode: string;
constructor(statusCode: string, status: string) {
super(`Unable to connect: ${statusCode} ${status}`);
this.status = status;
this.statusCode = statusCode;
}
}
class RemoteSocketNotListening extends TypedError {
port: number;
constructor(port: number) {
super(`Device is not listening on port ${port}`);
}
}
export const tunnelConnectionToDevice = ( export const tunnelConnectionToDevice = (
uuid: string, uuid: string,
@ -34,35 +52,29 @@ export const tunnelConnectionToDevice = (
password: token, password: token,
}; };
return (client: Socket): void => { return (client: Socket): Bluebird<void> =>
openPortThroughProxy(vpnUrl, 3128, auth, uuid, port) openPortThroughProxy(vpnUrl, 3128, auth, uuid, port)
.then(remote => { .then(remote => {
client.pipe(remote); client.pipe(remote);
remote.pipe(client); remote.pipe(client);
remote.on('error', err => { remote.on('error', err => {
console.error('Remote: ' + err); console.error('Remote: ' + err);
client.end(); client.end();
}); });
client.on('error', err => { client.on('error', err => {
console.error('Client: ' + err); console.error('Client: ' + err);
remote.end(); remote.end();
}); });
remote.on('close', () => { remote.on('close', () => {
client.end(); client.end();
}); });
client.on('close', () => { client.on('close', () => {
remote.end(); remote.end();
}); });
}) })
.tapCatch(err => { .tapCatch(() => {
console.error(err);
client.end(); client.end();
}); });
};
}); });
}; };
@ -85,6 +97,7 @@ const openPortThroughProxy = (
return new Bluebird.Promise<Socket>((resolve, reject) => { return new Bluebird.Promise<Socket>((resolve, reject) => {
const proxyTunnel = new Socket(); const proxyTunnel = new Socket();
proxyTunnel.on('error', reject);
proxyTunnel.connect(proxyPort, proxyServer, () => { proxyTunnel.connect(proxyPort, proxyServer, () => {
const proxyConnectionHandler = (data: Buffer) => { const proxyConnectionHandler = (data: Buffer) => {
proxyTunnel.removeListener('data', proxyConnectionHandler); proxyTunnel.removeListener('data', proxyConnectionHandler);
@ -92,16 +105,20 @@ const openPortThroughProxy = (
const [, httpStatusCode, ...httpMessage] = httpStatus.split(' '); const [, httpStatusCode, ...httpMessage] = httpStatus.split(' ');
if (parseInt(httpStatusCode) === 200) { if (parseInt(httpStatusCode) === 200) {
proxyTunnel.setTimeout(0);
resolve(proxyTunnel); resolve(proxyTunnel);
} else { } else {
console.error( reject(
`Connection failed. ${httpStatusCode} ${httpMessage.join(' ')}`, new UnableToConnectError(httpStatusCode, httpMessage.join(' ')),
); );
reject(new UnableToConnectError());
} }
}; };
proxyTunnel.on('timeout', () => {
reject(new RemoteSocketNotListening(devicePort));
});
proxyTunnel.on('data', proxyConnectionHandler); proxyTunnel.on('data', proxyConnectionHandler);
proxyTunnel.setTimeout(PROXY_CONNECT_TIMEOUT_MS);
proxyTunnel.write(httpHeaders.join('\r\n').concat('\r\n\r\n')); proxyTunnel.write(httpHeaders.join('\r\n').concat('\r\n\r\n'));
}); });
}); });