forked from coolaj86/goldilocks.js
		
	
		
			
				
	
	
		
			330 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			330 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
'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 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 deps.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() {
 | 
						|
    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 ({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 gw = await network.getGatewayIpAsync();
 | 
						|
    var addr = await network.getPrivateIpAsync();
 | 
						|
    if (localAddr === addr && gateway === gw) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    localAddr = addr;
 | 
						|
    gateway = gw;
 | 
						|
    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.
 | 
						|
    if (!notLooped.length) {
 | 
						|
      if (tunnelActive) {
 | 
						|
        await disconnectTunnels();
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      if (!tunnelActive) {
 | 
						|
        await connectAllTunnels();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  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
 | 
						|
  };
 | 
						|
};
 |