From a95031cc2896b47ff202326ef41a275d22d45128 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Thu, 27 Apr 2017 19:29:16 -0600 Subject: [PATCH 1/8] added ability to add/clear tokens on active websocket --- package.json | 5 ++-- wsclient.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 42ae3bc..6e7cc92 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,12 @@ }, "homepage": "https://git.daplie.com/Daplie/node-tunnel-client#readme", "dependencies": { + "bluebird": "^3.5.0", "commander": "^2.9.0", - "oauth3.js": "git+https://git.daplie.com/OAuth3/oauth3.js.git#v1", "jsonwebtoken": "^7.1.9", + "oauth3.js": "git+https://git.daplie.com/OAuth3/oauth3.js.git#v1", "sni": "^1.0.0", - "tunnel-packer": "^1.1.0", + "tunnel-packer": "^1.2.0", "ws": "^2.2.3" } } diff --git a/wsclient.js b/wsclient.js index bebcf46..231abff 100644 --- a/wsclient.js +++ b/wsclient.js @@ -2,6 +2,7 @@ 'use strict'; var WebSocket = require('ws'); +var PromiseA = require('bluebird'); var sni = require('sni'); var Packer = require('tunnel-packer'); @@ -69,8 +70,61 @@ function run(copts) { } }; + var pendingCommands = {}; + function sendCommand(name) { + var id = Math.ceil(1e9 * Math.random()); + var cmd = [id, name].concat(Array.prototype.slice.call(arguments, 1)); + + wsHandlers.sendMessage(Packer.pack(null, cmd, 'control')); + setTimeout(function () { + if (pendingCommands[id]) { + console.warn('command', id, 'timed out'); + pendingCommands[id]({ + message: 'response not received in time' + , code: 'E_TIMEOUT' + }); + } + }, pongTimeout); + + return new PromiseA(function (resolve, reject) { + pendingCommands[id] = function (err, result) { + delete pendingCommands[id]; + if (err) { + reject(err); + } else { + resolve(result); + } + }; + }); + } + var packerHandlers = { - onmessage: function (opts) { + oncontrol: function (opts) { + var cmd, err; + try { + cmd = JSON.parse(opts.data.toString()); + } catch (err) {} + if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') { + console.warn('received bad command "' + opts.data.toString() + '"'); + return; + } + + if (cmd[0] < 0) { + var cb = pendingCommands[-cmd[0]]; + if (!cb) { + console.warn('received response for unknown request:', cmd); + } else { + cb.apply(null, cmd.slice(1)); + } + return; + } + + // TODO: handle a "hello" message that let's us know we're authenticated. + err = { message: 'unknown command '+cmd[1], code: 'E_UNKNOWN_COMMAND' }; + + wsHandlers.sendMessage(Packer.pack(null, [-cmd[0], err], 'control')); + } + , onmessage: function (opts) { var net = copts.net || require('net'); var cid = Packer.addrToId(opts); var service = opts.service.toLowerCase(); @@ -222,6 +276,12 @@ function run(copts) { clearTimeout(timeoutId); wstunneler = null; clientHandlers.closeAll(); + Object.keys(pendingCommands).forEach(function (id) { + pendingCommands[id]({ + message: 'websocket connection closed before response' + , code: 'E_CONN_CLOSED' + }); + }); if (!authenticated) { console.info('[close] failed on first attempt... check authentication.'); @@ -297,6 +357,12 @@ function run(copts) { } } } + , append: function (token) { + return sendCommand('add_token', token); + } + , clear: function (token) { + return sendCommand('delete_token', token || '*'); + } }; } From 876fa47e02637f774ffc38692dabc0aa064a7413 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 28 Apr 2017 10:57:48 -0600 Subject: [PATCH 2/8] changed adding tokens to work on reconnect --- wsclient.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/wsclient.js b/wsclient.js index 231abff..0961437 100644 --- a/wsclient.js +++ b/wsclient.js @@ -7,7 +7,7 @@ var sni = require('sni'); var Packer = require('tunnel-packer'); function run(copts) { - var tunnelUrl = copts.stunneld.replace(/\/$/, '') + '/?access_token=' + copts.token; + var tokens = [ copts.token ]; var activityTimeout = copts.activityTimeout || 2*60*1000; var pongTimeout = copts.pongTimeout || 10*1000; @@ -224,7 +224,6 @@ function run(copts) { } }; - var retry = true; var lastActivity; var timeoutId; var wsHandlers = { @@ -269,6 +268,16 @@ function run(copts) { console.info("[open] connected to '" + copts.stunneld + "'"); wsHandlers.refreshTimeout(); timeoutId = setTimeout(wsHandlers.checkTimeout, activityTimeout); + + tokens.forEach(function (jwtoken) { + sendCommand('add_token', jwtoken) + .catch(function (err) { + console.error('failed re-adding token', jwtoken, 'after reconnect', err); + // Not sure if we should do something like remove the token here. It worked + // once or it shouldn't have stayed in the list, so it's less certain why + // it would have failed here. + }); + }); } , onClose: function () { @@ -287,7 +296,7 @@ function run(copts) { console.info('[close] failed on first attempt... check authentication.'); timeoutId = null; } - else if (retry) { + else if (tokens.length) { console.info('[retry] disconnected and waiting...'); timeoutId = setTimeout(connect, 5000); } @@ -314,13 +323,14 @@ function run(copts) { }; function connect() { - if (!retry) { + if (!tokens.length) { return; } timeoutId = null; var machine = require('tunnel-packer').create(packerHandlers); console.info("[connect] '" + copts.stunneld + "'"); + var tunnelUrl = copts.stunneld.replace(/\/$/, '') + '/?access_token=' + tokens[0]; wstunneler = new WebSocket(tunnelUrl, { rejectUnauthorized: !copts.insecure }); wstunneler.on('open', wsHandlers.onOpen); wstunneler.on('close', wsHandlers.onClose); @@ -342,7 +352,7 @@ function run(copts) { return { end: function() { - retry = false; + tokens.length = 0; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; @@ -358,10 +368,41 @@ function run(copts) { } } , append: function (token) { - return sendCommand('add_token', token); + if (tokens.indexOf(token) >= 0) { + return PromiseA.resolve(); + } + tokens.push(token); + + var prom = sendCommand('add_token', token); + prom.catch(function (err) { + console.error('adding token', token, 'failed:', err); + // Most probably an invalid token of some kind, so we don't really want to keep it. + tokens.splice(tokens.indexOf(token)); + }); + + return prom; } , clear: function (token) { - return sendCommand('delete_token', token || '*'); + if (typeof token === 'undefined') { + token = '*'; + } + + if (token === '*') { + tokens.length = 0; + } else { + var index = tokens.indexOf(token); + if (index < 0) { + return PromiseA.resolve(); + } + tokens.splice(index); + } + + var prom = sendCommand('delete_token', token); + prom.catch(function (err) { + console.error('clearing token', token, 'failed:', err); + }); + + return prom; } }; } From 12faab1acfc89e29fb7e213310052dd8a0849835 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 28 Apr 2017 15:02:44 -0600 Subject: [PATCH 3/8] added handling for 'hello' from the server --- wsclient.js | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/wsclient.js b/wsclient.js index 0961437..921fea2 100644 --- a/wsclient.js +++ b/wsclient.js @@ -98,6 +98,18 @@ function run(copts) { }); } + function sendAllTokens() { + tokens.forEach(function (jwtoken) { + sendCommand('add_token', jwtoken) + .catch(function (err) { + console.error('failed re-adding token', jwtoken, 'after reconnect', err); + // Not sure if we should do something like remove the token here. It worked + // once or it shouldn't have stayed in the list, so it's less certain why + // it would have failed here. + }); + }); + } + var packerHandlers = { oncontrol: function (opts) { var cmd, err; @@ -119,8 +131,22 @@ function run(copts) { return; } - // TODO: handle a "hello" message that let's us know we're authenticated. - err = { message: 'unknown command '+cmd[1], code: 'E_UNKNOWN_COMMAND' }; + if (cmd[0] === 0) { + console.warn('received dis-associated error from server', cmd[1]); + return; + } + + if (cmd[1] === 'hello') { + // We only get the 'hello' event after the token has been validated + authenticated = true; + sendAllTokens(); + // TODO: handle the versions and commands provided by 'hello' - isn't super important + // yet since there is only one version and set up commands. + err = null; + } + else { + err = { message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' }; + } wsHandlers.sendMessage(Packer.pack(null, [-cmd[0], err], 'control')); } @@ -134,8 +160,6 @@ function run(copts) { var str; var m; - authenticated = true; - if (localclients[cid]) { //console.log("[=>] received data from '" + cid + "' =>", opts.data.byteLength); localclients[cid].write(opts.data); @@ -268,16 +292,6 @@ function run(copts) { console.info("[open] connected to '" + copts.stunneld + "'"); wsHandlers.refreshTimeout(); timeoutId = setTimeout(wsHandlers.checkTimeout, activityTimeout); - - tokens.forEach(function (jwtoken) { - sendCommand('add_token', jwtoken) - .catch(function (err) { - console.error('failed re-adding token', jwtoken, 'after reconnect', err); - // Not sure if we should do something like remove the token here. It worked - // once or it shouldn't have stayed in the list, so it's less certain why - // it would have failed here. - }); - }); } , onClose: function () { From 3b1fc8e4cab8babb3db01be2e4c861260ca0fbb7 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 28 Apr 2017 15:28:21 -0600 Subject: [PATCH 4/8] handled case where append is called with closed websocket --- wsclient.js | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/wsclient.js b/wsclient.js index 921fea2..90d2116 100644 --- a/wsclient.js +++ b/wsclient.js @@ -110,6 +110,8 @@ function run(copts) { }); } + var connCallback; + var packerHandlers = { oncontrol: function (opts) { var cmd, err; @@ -133,6 +135,9 @@ function run(copts) { if (cmd[0] === 0) { console.warn('received dis-associated error from server', cmd[1]); + if (connCallback) { + connCallback(cmd[1]); + } return; } @@ -140,6 +145,9 @@ function run(copts) { // We only get the 'hello' event after the token has been validated authenticated = true; sendAllTokens(); + if (connCallback) { + connCallback(); + } // TODO: handle the versions and commands provided by 'hello' - isn't super important // yet since there is only one version and set up commands. err = null; @@ -340,6 +348,10 @@ function run(copts) { if (!tokens.length) { return; } + if (wstunneler) { + console.warn('attempted to connect with connection already active'); + return; + } timeoutId = null; var machine = require('tunnel-packer').create(packerHandlers); @@ -386,8 +398,36 @@ function run(copts) { return PromiseA.resolve(); } tokens.push(token); + var prom; + if (tokens.length === 1 && !wstunneler) { + // We just added the only token in the list, and the websocket connection isn't up + // so we need to restart the connection. + if (timeoutId) { + // Handle the case were the last token was removed and this token added between + // reconnect attempts to make sure we don't try openning multiple connections. + clearTimeout(timeoutId); + timeoutId = null; + } + + // We want this case to behave as much like the other case as we can, but we don't have + // the same kind of reponses when we open brand new connections, so we have to rely on + // the 'hello' and the 'un-associated' error commands to determine if the token is good. + prom = new PromiseA(function (resolve, reject) { + connCallback = function (err) { + connCallback = null; + if (err) { + reject(err); + } else { + resolve(); + } + }; + }); + connect(); + } + else { + prom = sendCommand('add_token', token); + } - var prom = sendCommand('add_token', token); prom.catch(function (err) { console.error('adding token', token, 'failed:', err); // Most probably an invalid token of some kind, so we don't really want to keep it. From 61c7bd5ad63db3275dc801af31b21dfbf2a79051 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Thu, 25 May 2017 13:46:09 -0600 Subject: [PATCH 5/8] allow client to be created with no initial tokens --- wsclient.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/wsclient.js b/wsclient.js index 90d2116..f4b540c 100644 --- a/wsclient.js +++ b/wsclient.js @@ -7,9 +7,15 @@ var sni = require('sni'); var Packer = require('tunnel-packer'); function run(copts) { - var tokens = [ copts.token ]; var activityTimeout = copts.activityTimeout || 2*60*1000; var pongTimeout = copts.pongTimeout || 10*1000; + // Allow the tunnel client to be created with no token. This will prevent the connection from + // being established initialy and allows the caller to use `.append` for the first token so + // they can get a promise that will provide feedback about invalid tokens. + var tokens = []; + if (copts.token) { + tokens.push(copts.token); + } var wstunneler; var authenticated = false; @@ -149,7 +155,7 @@ function run(copts) { connCallback(); } // TODO: handle the versions and commands provided by 'hello' - isn't super important - // yet since there is only one version and set up commands. + // yet since there is only one version and set of commands. err = null; } else { From e6da8277c44d21e76af56a968a6b6aeec5c3b353 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Mon, 5 Jun 2017 11:20:15 -0600 Subject: [PATCH 6/8] added audience to the tokens we generate --- bin/stunnel.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/bin/stunnel.js b/bin/stunnel.js index 66a9012..fc0c5b6 100755 --- a/bin/stunnel.js +++ b/bin/stunnel.js @@ -120,8 +120,8 @@ program }) .option('-k --insecure', 'Allow TLS connections to stunneld without valid certs (rejectUnauthorized: false)') .option('--locals ', 'comma separated list of : to which matching incoming http and https should forward (reverse proxy). Ex: https:8443,smtps:8465', collectProxies, [ ]) // --reverse-proxies - .option('--domains ', 'comma separated list of domain names to set to the tunnel (to caputer a specific protocol to a specific local port use the format https:example.com:1337 instead). Ex: example.com,example.net', collectDomains, [ ]) - .option('--device [HOSTNAME]', 'Tunnel all domains associated with this device instead of specific domainnames. Use with --locals :*:. Ex: macbook-pro.local (the output of `hostname`)') + .option('--domains ', 'comma separated list of domain names to set to the tunnel (to capture a specific protocol to a specific local port use the format https:example.com:1337 instead). Ex: example.com,example.net', collectDomains, [ ]) + .option('--device [HOSTNAME]', 'Tunnel all domains associated with this device instead of specific domainnames. Use with --locals :. Ex: macbook-pro.local (the output of `hostname`)') .option('--stunneld ', 'the domain (or ip address) at which you are running stunneld.js (the proxy)') // --proxy .option('--secret ', 'the same secret used by stunneld (used for JWT authentication)') .option('--token ', 'a pre-generated token for use with stunneld (instead of generating one with --secret)') @@ -181,21 +181,23 @@ function rawTunnel() { return; } - if (!program.token) { - var jwt = require('jsonwebtoken'); - var tokenData = { - domains: Object.keys(domainsMap).filter(Boolean) - }; - - program.token = jwt.sign(tokenData, program.secret); - } - var location = url.parse(program.stunneld); if (!location.protocol || /\./.test(location.protocol)) { program.stunneld = 'wss://' + program.stunneld; location = url.parse(program.stunneld); } - program.stunneld = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : ''); + var aud = location.hostname + (location.port ? ':' + location.port : ''); + program.stunneld = location.protocol + '//' + aud; + + if (!program.token) { + var jwt = require('jsonwebtoken'); + var tokenData = { + domains: Object.keys(domainsMap).filter(Boolean) + , aud: aud + }; + + program.token = jwt.sign(tokenData, program.secret); + } connectTunnel(); } From d6cad7cb65a23153077f23efdb1d81268b768d86 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Mon, 5 Jun 2017 11:20:58 -0600 Subject: [PATCH 7/8] added support for wildcard domains --- wsclient.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/wsclient.js b/wsclient.js index f4b540c..6874aaa 100644 --- a/wsclient.js +++ b/wsclient.js @@ -202,7 +202,26 @@ function run(copts) { return; } - port = portList[servername] || portList['*']; + port = portList[servername]; + if (!port) { + // Check for any wildcard domains, sorted longest to shortest so the one with the + // biggest natural match will be found first. + Object.keys(portList).filter(function (pattern) { + return pattern[0] === '*' && pattern.length > 1; + }).sort(function (a, b) { + return b.length - a.length; + }).some(function (pattern) { + var subPiece = pattern.slice(1); + if (subPiece === servername.slice(-subPiece.length)) { + port = portList[pattern]; + return true; + } + }); + } + if (!port) { + port = portList['*']; + } + var createOpts = { port: port , host: '127.0.0.1' From 5e8d99a34c9c7f484228ed1e76b0fc640df38a5e Mon Sep 17 00:00:00 2001 From: tigerbot Date: Mon, 5 Jun 2017 11:28:53 -0600 Subject: [PATCH 8/8] use `.destroy` if `.end` fails to close connection --- wsclient.js | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/wsclient.js b/wsclient.js index 6874aaa..67ad653 100644 --- a/wsclient.js +++ b/wsclient.js @@ -6,6 +6,12 @@ var PromiseA = require('bluebird'); var sni = require('sni'); var Packer = require('tunnel-packer'); +function timeoutPromise(duration) { + return new PromiseA(function (resolve) { + setTimeout(resolve, duration); + }); +} + function run(copts) { var activityTimeout = copts.activityTimeout || 2*60*1000; var pongTimeout = copts.pongTimeout || 10*1000; @@ -38,37 +44,35 @@ function run(copts) { } console.log('[closeSingle]', cid); - try { - localclients[cid].end(); - setTimeout(function () { + PromiseA.resolve() + .then(function () { + localclients[cid].end(); + return timeoutPromise(500); + }) + .then(function () { if (localclients[cid]) { - console.warn('[closeSingle]', cid, 'connection still present'); + console.warn('[closeSingle]', cid, 'connection still present after calling `end`'); + localclients[cid].destroy(); + return timeoutPromise(500); + } + }) + .then(function () { + if (localclients[cid]) { + console.error('[closeSingle]', cid, 'connection still present after calling `destroy`'); delete localclients[cid]; } - }, 500); - } catch (err) { - console.warn('[closeSingle] failed to close connection', cid, err); - delete localclients[cid]; - } + }) + .catch(function (err) { + console.error('[closeSingle] failed to close connection', cid, err); + delete localclients[cid]; + }) + ; } , closeAll: function () { console.log('[closeAll]'); Object.keys(localclients).forEach(function (cid) { - try { - localclients[cid].end(); - } catch (err) { - console.warn('[closeAll] failed to close connection', cid, err); - } + clientHandlers.closeSingle(cid); }); - - setTimeout(function () { - Object.keys(localclients).forEach(function (cid) { - if (localclients[cid]) { - console.warn('[closeAll]', cid, 'connection still present'); - delete localclients[cid]; - } - }); - }, 500); } , count: function () {