'use strict'; module.exports.create = function (deps, conf) { var dns = deps.PromiseA.promisifyAll(require('dns')); 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 tunnelClients = require('./tunnel-client-manager').create(deps, conf); var equal = require('deep-equal'); var loopbackDomain; function iterateAllModules(action, curConf) { curConf = curConf || conf; var promises = curConf.ddns.modules.map(function (mod) { return action(mod, mod.domains); }); curConf.domains.forEach(function (dom) { if (!dom.modules || !Array.isArray(dom.modules.ddns) || !dom.modules.ddns.length) { return null; } // For the time being all of our things should only be tried once (regardless if it succeeded) // TODO: revisit this behavior when we support multiple ways of setting records, and/or // if we want to allow later modules to run if early modules fail. promises.push(dom.modules.ddns.reduce(function (prom, mod) { if (prom) { return prom; } return action(mod, dom.names); }, null)); }); return deps.PromiseA.all(promises.filter(Boolean)); } async function getSession(id) { var session = await deps.storage.tokens.get(id); if (!session) { throw new Error('no user token with ID "'+id+'"'); } return session; } var tunnelActive = false; async function startTunnel(tunnelSession, mod, domainList) { try { var dnsSession = await getSession(mod.tokenId); var tunnelDomain = await tunnelClients.start(tunnelSession || dnsSession, domainList); var addrList; try { addrList = await dns.resolve4Async(tunnelDomain); } catch (e) {} if (!addrList || !addrList.length) { try { addrList = await dns.resolve6Async(tunnelDomain); } catch (e) {} } if (!addrList || !addrList.length || !addrList[0]) { throw new Error('failed to lookup IP for tunnel domain "' + tunnelDomain + '"'); } await dnsCtrl.setDeviceAddress(dnsSession, addrList[0], domainList); } catch (err) { console.log('error starting tunnel for', domainList.join(', ')); console.log(err); } } async function connectAllTunnels() { 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 iterateAllModules(function (mod, domainList) { if (mod.type !== 'dns@oauth3.org') { return null; } return startTunnel(tunnelSession, mod, domainList); }); tunnelActive = true; } async function disconnectTunnels() { tunnelClients.disconnect(); tunnelActive = false; await Promise.resolve(); } async function checkTunnelTokens() { var oldTokens = 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(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 ({mod, domainList}) { return startTunnel(tunnelSession, mod, domainList); })); } var localAddr, gateway; async function checkNetworkEnv() { // Since we can't detect the OS level events when a user plugs in an ethernet cable to recheck // what network environment we are in we check our local network address and the gateway to // determine if we need to run the loopback check and router configuration again. var addr = await network.getPrivateIpAsync(); // Until the author of the `network` package publishes the pull request we gave him // checking the gateway on our units fails because we have the busybox versions of // the linux commands. Gateway is realistically less important than address, so if // we fail in getting it go ahead and use the null value. var gw; try { gw = await network.getGatewayIpAsync(); } catch (err) { gw = null; } if (localAddr === addr && gateway === gw) { return; } var loopResult = await loopback(loopbackDomain); var notLooped = Object.keys(loopResult.ports).filter(function (port) { return !loopResult.ports[port]; }); // if (notLooped.length) { // // TODO: try to automatically configure router to forward ports to us. // } // If we are on a public address or all ports we are listening on are forwarded to us then // we don't need the tunnel and we can set the DNS records for all our domains to our public // address. Otherwise we need to use the tunnel to accept traffic. Also since the tunnel will // only be listening on ports 80 and 443 if those are forwarded to us we don't want the tunnel. if (!notLooped.length || (loopResult.ports['80'] && loopResult.ports['443'])) { if (tunnelActive) { await disconnectTunnels(); } } else { if (!tunnelActive) { await connectAllTunnels(); } } // Don't assign these until the end of the function. This means that if something failed // in the loopback or tunnel connection that we will try to go through the whole process // again next time and hopefully the error is temporary (but if not I'm not sure what the // correct course of action would be anyway). localAddr = addr; gateway = gw; } var publicAddress; async function recheckPubAddr() { await checkNetworkEnv(); if (tunnelActive) { return; } var addr = await loopback.checkPublicAddr(loopbackDomain); if (publicAddress === addr) { return; } if (conf.debug) { console.log('previous public address',publicAddress, 'does not match current public address', addr); } publicAddress = addr; await iterateAllModules(function setModuleDNS(mod, domainList) { if (mod.type !== 'dns@oauth3.org' || mod.disabled) { return null; } return getSession(mod.tokenId).then(function (session) { return dnsCtrl.setDeviceAddress(session, addr, domainList); }).catch(function (err) { console.log('error setting DNS records for', domainList.join(', ')); console.log(err); }); }); } function getModuleDiffs(prevConf) { var prevMods = {}; var curMods = {}; // this returns a Promise, but since the functions we use are synchronous // and change our enclosed variables we don't need to wait for the return. iterateAllModules(function (mod, domainList) { if (mod.type !== 'dns@oauth3.org') { return; } prevMods[mod.id] = { mod, domainList }; return true; }, prevConf); iterateAllModules(function (mod, domainList) { if (mod.type !== 'dns@oauth3.org') { return; } curMods[mod.id] = { mod, domainList }; return true; }); // Filter out all of the modules that are exactly the same including domainList // since there is no required action to transition. Object.keys(prevMods).map(function (id) { if (equal(prevMods[id], curMods[id])) { delete prevMods[id]; delete curMods[id]; } }); return {prevMods, curMods}; } async function cleanOldDns(prevConf) { var {prevMods, curMods} = getModuleDiffs(prevConf); // Then remove DNS records for the domains that we are no longer responsible for. await Promise.all(Object.values(prevMods).map(function ({mod, domainList}) { var oldDomains; if (!curMods[mod.id] || mod.tokenId !== curMods[mod.id].mod.tokenId) { oldDomains = domainList.slice(); } else { oldDomains = domainList.filter(function (domain) { return curMods[mod.id].domainList.indexOf(domain) < 0; }); } if (conf.debug) { console.log('removing old domains for module', mod.id, oldDomains.join(', ')); } if (!oldDomains.length) { return; } return getSession(mod.tokenId).then(function (session) { return dnsCtrl.removeDomains(session, oldDomains); }); }).filter(Boolean)); } async function setNewDns(prevConf) { var {prevMods, curMods} = getModuleDiffs(prevConf); // And add DNS records for any newly added domains. await Promise.all(Object.values(curMods).map(function ({mod, domainList}) { var newDomains; if (!prevMods[mod.id] || mod.tokenId !== prevMods[mod.id].mod.tokenId) { newDomains = domainList.slice(); } else { newDomains = domainList.filter(function (domain) { return prevMods[mod.id].domainList.indexOf(domain) < 0; }); } if (conf.debug) { console.log('adding new domains for module', mod.id, newDomains.join(', ')); } if (!newDomains.length) { return; } return getSession(mod.tokenId).then(function (session) { return dnsCtrl.setDeviceAddress(session, publicAddress, newDomains); }); }).filter(Boolean)); } function check() { recheckPubAddr().catch(function (err) { console.error('failed to handle all actions needed for DDNS'); console.error(err); }); } check(); setInterval(check, 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 (!curConf) { // 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)); return; } cleanOldDns(curConf).then(function () { if (!tunnelActive) { return setNewDns(curConf); } if (equal(curConf.ddns.tunnel, conf.ddns.tunnel)) { return checkTunnelTokens(); } else { return disconnectTunnels().then(connectAllTunnels); } }).catch(function (err) { console.error('error transitioning DNS between configurations'); console.error(err); }).then(function () { // 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 , getDeviceAddresses: dnsCtrl.getDeviceAddresses , recheckPubAddr: recheckPubAddr , updateConf: updateConf }; };