forked from coolaj86/goldilocks.js
345 lines
12 KiB
JavaScript
345 lines
12 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 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
|
|
};
|
|
};
|