From acf2fd7764cb39dcccb5c22360cc300802bf7c6e Mon Sep 17 00:00:00 2001 From: tigerbot Date: Thu, 19 Oct 2017 17:45:05 -0600 Subject: [PATCH] looking at active tunnel session on DDNS config update --- .jshintrc | 1 + lib/admin/config.js | 1 + lib/ddns/index.js | 98 +++++++++++++++++++++----- lib/storage.js | 5 +- lib/tunnel-client-manager.js | 131 ++++++++++++++++++++--------------- package-lock.json | 5 ++ package.json | 1 + 7 files changed, 163 insertions(+), 79 deletions(-) diff --git a/.jshintrc b/.jshintrc index 63801ce..7c6a6ae 100644 --- a/.jshintrc +++ b/.jshintrc @@ -13,4 +13,5 @@ , "latedef": true , "curly": true , "trailing": true +, "esversion": 6 } diff --git a/lib/admin/config.js b/lib/admin/config.js index 607e2a6..41f40a4 100644 --- a/lib/admin/config.js +++ b/lib/admin/config.js @@ -335,6 +335,7 @@ class ConfigChanger { constructor(start) { Object.assign(this, JSON.parse(JSON.stringify(start))); delete this.device; + delete this.debug; this.domains = new DomainList(this.domains); this.http.modules = new ModuleList(this.http.modules); diff --git a/lib/ddns/index.js b/lib/ddns/index.js index 4939d68..036bb02 100644 --- a/lib/ddns/index.js +++ b/lib/ddns/index.js @@ -4,19 +4,9 @@ module.exports.create = function (deps, conf) { var network = deps.PromiseA.promisifyAll(deps.recase.camelCopy(require('network'))); var loopback = require('./loopback').create(deps, conf); var dnsCtrl = require('./dns-ctrl').create(deps, conf); + var equal = require('deep-equal'); var loopbackDomain; - function updateConf() { - loopbackDomain = 'oauth3.org'; - if (conf.ddns && conf.ddns.loopback) { - if (conf.ddns.loopback.type === 'tunnel@oauth3.org' && conf.ddns.loopback.domain) { - loopbackDomain = conf.ddns.loopback.domain; - } else { - console.error('invalid loopback configuration: bad type or missing domain'); - } - } - } - updateConf(); function iterateAllModules(action) { var promises = conf.ddns.modules.map(function (mod) { @@ -57,13 +47,13 @@ module.exports.create = function (deps, conf) { tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId); } - await iterateAllModules(function startTunnel(mod, domainsList) { + await iterateAllModules(function startTunnel(mod, domainList) { if (mod.type !== 'dns@oauth3.org') { return null; } - return getSession(mod.token_id).then(function (dnsSession) { - return deps.tunnelClients.start(tunnelSession || dnsSession, domainsList); + return getSession(mod.tokenId).then(function (dnsSession) { + return deps.tunnelClients.start(tunnelSession || dnsSession, domainList); }).catch(function (err) { - console.log('error starting tunnel for', domainsList.join(', ')); + console.log('error starting tunnel for', domainList.join(', ')); console.log(err); }); }); @@ -73,6 +63,44 @@ module.exports.create = function (deps, conf) { async function disconnectTunnel() { deps.tunnelClients.disconnect(); tunnelActive = false; + await Promise.resolve(); + } + async function checkTunnelTokens() { + var oldTokens = deps.tunnelClients.current(); + + var newTokens = await iterateAllModules(function checkTokens(mod, domainList) { + if (mod.type !== 'dns@oauth3.org') { return null; } + + var domainStr = domainList.slice().sort().join(','); + // If there is already a token handling exactly the domains this modules + // needs handled remove it from the list of tokens to be removed. Otherwise + // return the module and domain list so we can get new tokens. + if (oldTokens[domainStr]) { + delete oldTokens[domainStr]; + } else { + return Promise.resolve({ mod, domainList }); + } + }); + + await Promise.all(Object.values(oldTokens).map(deps.tunnelClients.remove)); + + if (!newTokens.length) { return; } + + var tunnelSession; + if (conf.ddns.tunnel) { + // In the case of a non-existant token, I'm not sure if we want to throw here and prevent + // any tunnel connections, or if we want to ignore it and fall back to the DNS tokens + tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId); + } + + await Promise.all(newTokens.map(function startTunnel({mod, domainList}) { + return getSession(mod.tokenId).then(function (dnsSession) { + return deps.tunnelClients.start(tunnelSession || dnsSession, domainList); + }).catch(function (err) { + console.log('error starting tunnel for', domainList.join(', ')); + console.log(err); + }); + })); } var localAddr, gateway; @@ -128,13 +156,13 @@ module.exports.create = function (deps, conf) { } publicAddress = addr; - await iterateAllModules(function setModuleDNS(mod, domainsList) { + await iterateAllModules(function setModuleDNS(mod, domainList) { if (mod.type !== 'dns@oauth3.org' || mod.disabled) { return null; } - return getSession(mod.token_id).then(function (session) { - return dnsCtrl.setDeviceAddress(session, addr, domainsList); + return getSession(mod.tokenId).then(function (session) { + return dnsCtrl.setDeviceAddress(session, addr, domainList); }).catch(function (err) { - console.log('error setting DNS records for', domainsList.join(', ')); + console.log('error setting DNS records for', domainList.join(', ')); console.log(err); }); }); @@ -143,6 +171,38 @@ module.exports.create = function (deps, conf) { recheckPubAddr(); setInterval(recheckPubAddr, 5*60*1000); + var curConf; + function updateConf() { + if (curConf && equal(curConf.ddns, conf.ddns) && equal(curConf.domains, conf.domains)) { + // We could update curConf, but since everything we care about is the same... + return; + } + + if (!curConf || !equal(curConf.ddns.loopback, conf.ddns.loopback)) { + loopbackDomain = 'oauth3.org'; + if (conf.ddns && conf.ddns.loopback) { + if (conf.ddns.loopback.type === 'tunnel@oauth3.org' && conf.ddns.loopback.domain) { + loopbackDomain = conf.ddns.loopback.domain; + } else { + console.error('invalid loopback configuration: bad type or missing domain'); + } + } + } + + if (tunnelActive) { + if (!curConf || !equal(curConf.ddns.tunnel, conf.ddns.tunnel)) { + disconnectTunnel().then(connectTunnel); + } else { + checkTunnelTokens(); + } + } + + // We need to make a deep copy of the config so we can use it next time to + // compare and see what setup/cleanup is needed to adapt to the changes. + curConf = JSON.parse(JSON.stringify(conf)); + } + updateConf(); + return { loopbackServer: loopback.server , setDeviceAddress: dnsCtrl.setDeviceAddress diff --git a/lib/storage.js b/lib/storage.js index 5c2928f..56d73d4 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -141,9 +141,8 @@ module.exports.create = function (deps, conf) { } return read(self._filename).then(function (tokens) { - return self._convertToken(id, tokens[id]); - }).then(function (session) { - self._cache[id] = session; + self._cache[id] = self._convertToken(id, tokens[id]); + return self._cache[id]; }); } , save: function saveUserToken(newToken) { diff --git a/lib/tunnel-client-manager.js b/lib/tunnel-client-manager.js index 5355295..29105f5 100644 --- a/lib/tunnel-client-manager.js +++ b/lib/tunnel-client-manager.js @@ -2,31 +2,68 @@ module.exports.create = function (deps, config) { var stunnel = require('stunnel'); + var jwt = require('jsonwebtoken'); var activeTunnels = {}; + var activeDomains = {}; - function addToken(data) { + function fillData(data) { if (typeof data === 'string') { data = { jwt: data }; } + if (!data.jwt) { - return deps.PromiseA.reject(new Error("missing 'jwt' from tunnel data")); + throw new Error("missing 'jwt' from tunnel data"); } + var decoded = jwt.decode(data.jwt); + if (!decoded) { + throw new Error('invalid JWT'); + } + if (!data.tunnelUrl) { - var decoded; - try { - decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii')); - } catch (err) { - console.warn('invalid web token given to tunnel manager', err); - return deps.PromiseA.reject(err); - } if (!decoded.aud) { - console.warn('tunnel manager given token with no tunnelUrl or audience'); - var err = new Error('missing tunnelUrl and audience'); - return deps.PromiseA.reject(err); + throw new Error('missing tunnelUrl and audience'); } data.tunnelUrl = 'wss://' + decoded.aud + '/'; } + data.domains = (decoded.domains || []).slice().sort().join(','); + if (!data.domains) { + throw new Error('JWT contains no domains to be forwarded'); + } + + return data; + } + + async function removeToken(data) { + data = fillData(data); + + // Not sure if we might want to throw an error indicating the token didn't + // even belong to a server that existed, but since it never existed we can + // consider it as "removed". + if (!activeTunnels[data.tunnelUrl]) { + return; + } + + console.log('removing token from tunnel at', data.tunnelUrl); + return activeTunnels[data.tunnelUrl].clear(data.jwt).then(function () { + delete activeDomains[data.domains]; + }); + } + + async function addToken(data) { + data = fillData(data); + + if (activeDomains[data.domains]) { + // If already have a token with the exact same domains and to the same tunnel + // server there isn't really a need to add a new one + if (activeDomains[data.domains].tunnelUrl === data.tunnelUrl) { + return; + } + // Otherwise we want to detach from the other tunnel server in favor of the new one + console.warn('added token with the exact same domains as another'); + await removeToken(activeDomains[data.domains]); + } + if (!activeTunnels[data.tunnelUrl]) { console.log('creating new tunnel client for', data.tunnelUrl); // We create the tunnel without an initial token so we can append the token and @@ -48,11 +85,16 @@ module.exports.create = function (deps, config) { }); } - console.log('appending token to tunnel at', data.tunnelUrl); - return activeTunnels[data.tunnelUrl].append(data.jwt); + console.log('appending token to tunnel at', data.tunnelUrl, 'for domains', data.domains); + await activeTunnels[data.tunnelUrl].append(data.jwt); + + // Now that we know the tunnel server accepted our token we can save it + // to keep record of what domains we are handling and what tunnel server + // those domains should go to. + activeDomains[data.domains] = data; } - function acquireToken(session, domains) { + async function acquireToken(session, domains) { var OAUTH3 = deps.OAUTH3; // The OAUTH3 library stores some things on the root session object that we usually @@ -62,51 +104,21 @@ module.exports.create = function (deps, config) { session.scope = session.scope || session.token.scp; console.log('asking for tunnel token from', session.token.aud); - return OAUTH3.discover(session.token.aud).then(function (directives) { - var opts = { - api: 'tunnel.token' - , session: session - , data: { - domains: domains - , device: { - hostname: config.device.hostname - , id: config.device.uid || config.device.id - } + var opts = { + api: 'tunnel.token' + , session: session + , data: { + domains: domains + , device: { + hostname: config.device.hostname + , id: config.device.uid || config.device.id } - }; - - return OAUTH3.api(directives.api, opts).then(addToken); - }); - } - - function removeToken(data) { - if (typeof data === 'string') { - data = { jwt: data }; - } - if (!data.tunnelUrl) { - var decoded; - try { - decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii')); - } catch (err) { - console.warn('invalid web token given to tunnel manager', err); - return deps.PromiseA.reject(err); } - if (!decoded.aud) { - console.warn('tunnel manager given token with no tunnelUrl or audience'); - var err = new Error('missing tunnelUrl and audience'); - return deps.PromiseA.reject(err); - } - data.tunnelUrl = 'wss://' + decoded.aud + '/'; - } + }; - // Not sure if we actually want to return an error that the token didn't even belong to a - // server that existed, but since it never existed we can consider it as "removed". - if (!activeTunnels[data.tunnelUrl]) { - return deps.PromiseA.resolve(); - } - - console.log('removing token from tunnel at', data.tunnelUrl); - return activeTunnels[data.tunnelUrl].clear(data.jwt); + var directives = await OAUTH3.discover(session.token.aud); + var tokenData = await OAUTH3.api(directives.api, opts); + await addToken(tokenData); } function disconnectAll() { @@ -115,10 +127,15 @@ module.exports.create = function (deps, config) { }); } + function currentTokens() { + return JSON.parse(JSON.stringify(activeDomains)); + } + return { start: acquireToken , startDirect: addToken , remove: removeToken , disconnect: disconnectAll + , current: currentTokens }; }; diff --git a/package-lock.json b/package-lock.json index 49f699d..08e55c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -427,6 +427,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 1d70e3f..9028745 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "bluebird": "^3.4.6", "body-parser": "git+https://github.com/expressjs/body-parser.git#1.16.1", "commander": "^2.9.0", + "deep-equal": "^1.0.1", "dns-suite": "git+https://git@git.daplie.com/Daplie/dns-suite#v1", "express": "git+https://github.com/expressjs/express.git#4.x", "finalhandler": "^0.4.0",