goldilocks.js/bin/goldilocks.js
tigerbot 663fdba446 changed the valid UDP module from 'proxy' to 'forward'
forward is based on incoming port, while proxy is based on domains
	and we don't have any domain names for raw UDP or TCP
2017-10-12 14:35:19 -06:00

475 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env node
'use strict';
var cluster = require('cluster');
if (!cluster.isMaster) {
require('../lib/worker.js');
return;
}
var crypto = require('crypto');
var PromiseA = require('bluebird');
var fs = PromiseA.promisifyAll(require('fs'));
var configStorage;
function mergeSettings(orig, changes) {
Object.keys(changes).forEach(function (key) {
// TODO: use an API that can properly handle updating arrays.
if (!changes[key] || (typeof changes[key] !== 'object') || Array.isArray(changes[key])) {
orig[key] = changes[key];
}
else if (!orig[key] || typeof orig[key] !== 'object') {
orig[key] = changes[key];
}
else {
mergeSettings(orig[key], changes[key]);
}
});
}
function fixRawConfig(config) {
var updated = false;
// First converge all of the `bind` properties for protocols that are on top
// of TCP to `tcp.bind`.
if (config.tcp && config.tcp.bind && !Array.isArray(config.tcp.bind)) {
config.tcp.bind = [ config.tcp.bind ];
updated = true;
}
if (config.http && config.http.bind) {
config.tcp = config.tcp || { bind: [] };
config.tcp.bind = (config.tcp.bind || []).concat(config.http.bind);
delete config.http.bind;
updated = true;
}
if (config.tls && config.tls.bind) {
config.tcp = config.tcp || { bind: [] };
config.tcp.bind = (config.tcp.bind || []).concat(config.tls.bind);
delete config.tls.bind;
updated = true;
}
// Then we rename dns to udp since the only thing we currently do with those
// modules is proxy the packets without inspecting them at all.
if (config.dns) {
config.udp = config.dns;
delete config.dns;
updated = true;
}
// Convert all 'proxy' UDP modules to 'forward' modules that specify which
// incoming ports are relevant. Primarily to make 'proxy' modules consistent
// in needing relevant domain names.
if (config.udp && !Array.isArray(config.udp.bind)) {
config.udp.bind = [].concat(config.udp.bind || []);
updated = true;
}
if (config.udp && config.udp.modules) {
if (!config.udp.bind.length || !Array.isArray(config.udp.modules)) {
delete config.udp.modules;
updated = true;
} else {
config.udp.modules.forEach(function (mod) {
if (mod.type === 'proxy') {
mod.type = 'forward';
mod.ports = config.udp.bind.slice();
updated = true;
}
});
}
}
// This we take the old way of defining ACME options and put them into a tls module.
if (config.tls) {
var oldPropMap = {
email: 'email'
, acme_directory_url: 'server'
, challenge_type: 'challenge_type'
, servernames: 'approved_domains'
};
if (Object.keys(oldPropMap).some(config.tls.hasOwnProperty, config.tls)) {
updated = true;
if (config.tls.acme) {
console.warn('TLS config has `acme` field and old style definitions');
} else {
config.tls.acme = {};
Object.keys(oldPropMap).forEach(function (oldKey) {
if (config.tls[oldKey]) {
config.tls.acme[oldPropMap[oldKey]] = config.tls[oldKey];
}
});
}
}
if (config.tls.acme) {
updated = true;
config.tls.acme.domains = config.tls.acme.approved_domains;
delete config.tls.acme.approved_domains;
config.tls.modules = config.tls.modules || [];
config.tls.modules.push(Object.assign({}, config.tls.acme, {type: 'acme'}));
delete config.tls.acme;
}
}
// Then we make sure all modules have an ID and type, and makes sure all domains
// are in the right spot and also have an ID.
function updateModules(list) {
if (!Array.isArray(list)) {
return;
}
list.forEach(function (mod) {
if (!mod.id) {
mod.id = crypto.randomBytes(4).toString('hex');
updated = true;
}
if (mod.name) {
mod.type = mod.type || mod.name;
delete mod.name;
updated = true;
}
});
}
function moveDomains(name) {
if (!config[name].domains) {
return;
}
updated = true;
var domList = config[name].domains;
delete config[name].domains;
if (!Array.isArray(domList)) {
return;
}
if (!Array.isArray(config.domains)) {
config.domains = [];
}
domList.forEach(function (dom) {
updateModules(dom.modules);
var strDoms = dom.names.slice().sort().join(',');
var added = config.domains.some(function (existing) {
if (strDoms !== existing.names.slice().sort().join(',')) {
return;
}
existing.modules = existing.modules || {};
existing.modules[name] = (existing.modules[name] || []).concat(dom.modules);
return true;
});
if (added) {
return;
}
var newDom = {
id: crypto.randomBytes(4).toString('hex')
, names: dom.names
, modules: {}
};
newDom.modules[name] = dom.modules;
config.domains.push(newDom);
});
}
[ 'udp', 'tcp', 'http', 'tls' ].forEach(function (key) {
if (!config[key]) {
return;
}
updateModules(config[key].modules);
moveDomains(key);
});
return updated;
}
async function createStorage(filename, filetype) {
var recase = require('recase').create({});
var snakeCopy = recase.snakeCopy.bind(recase);
var camelCopy = recase.camelCopy.bind(recase);
var parse, dump;
if (filetype === 'json') {
parse = JSON.parse;
dump = function (arg) { return JSON.stringify(arg, null, ' '); };
} else {
var yaml = require('js-yaml');
parse = function (text) { return yaml.safeLoad(text) || {}; };
dump = yaml.safeDump;
}
async function read() {
var text;
try {
text = await fs.readFileAsync(filename);
} catch (err) {
if (err.code === 'ENOENT') {
return {};
}
throw err;
}
var rawConfig = parse(text);
if (fixRawConfig(rawConfig)) {
await fs.writeFileAsync(filename, dump(rawConfig));
text = await fs.readFileAsync(filename);
rawConfig = parse(text);
}
return rawConfig;
}
var result = {
read: function () {
return read().then(camelCopy);
}
, save: function (changes) {
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
return PromiseA.reject(new Error('invalid config'));
}
changes = snakeCopy(changes);
return read()
.then(snakeCopy)
.then(function (current) {
mergeSettings(current, changes);
// TODO: validate/lint the config before we actually write it.
return dump(current);
})
.then(function (newText) {
return fs.writeFileAsync(filename, newText);
})
.then(function () {
return result.read();
})
;
}
};
return result;
}
async function checkConfigLocation(cwd, configFile) {
cwd = cwd || process.cwd();
var path = require('path');
var filename, text;
if (configFile) {
filename = path.resolve(cwd, configFile);
try {
text = await fs.readFileAsync(filename);
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
if (path.extname(filename) === '.json') {
return { name: filename, type: 'json' };
} else {
return { name: filename, type: 'yaml' };
}
}
} else {
// Note that `path.resolve` can handle both relative and absolute paths.
var defLocations = [
path.resolve(cwd, 'goldilocks.yml')
, path.resolve(cwd, 'goldilocks.json')
, path.resolve(cwd, 'etc/goldilocks/goldilocks.yml')
, '/etc/goldilocks/goldilocks.yml'
];
var ind;
for (ind = 0; ind < defLocations.length; ind += 1) {
try {
text = await fs.readFileAsync(defLocations[ind]);
filename = defLocations[ind];
break;
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
}
if (!filename) {
filename = defLocations[0];
text = '';
}
}
try {
JSON.parse(text);
return { name: filename, type: 'json' };
} catch (err) {}
try {
require('js-yaml').safeLoad(text);
return { name: filename, type: 'yaml' };
} catch (err) {}
throw new Error('Could not load "' + filename + '" as JSON nor YAML');
}
async function createConfigStorage(args) {
var result = await checkConfigLocation(args.cwd, args.config);
console.log('config file', result.name, 'is of type', result.type);
configStorage = await createStorage(result.name, result.type);
return configStorage.read();
}
var tcpProm;
function fillConfig(config, args) {
config.debug = config.debug || args.debug;
config.socks5 = config.socks5 || { enabled: false };
config.ddns = config.ddns || { enabled: false };
// Use Object.assign to copy any real config values over the default values so we can
// easily make sure all the fields we need exist .
var mdnsDefaults = { disabled: false, port: 5353, broadcast: '224.0.0.251', ttl: 300 };
config.mdns = Object.assign(mdnsDefaults, config.mdns);
if (!Array.isArray(config.domains)) {
config.domains = [];
}
function fillComponent(name, fillBind) {
if (!config[name]) {
config[name] = {};
}
if (!Array.isArray(config[name].modules)) {
config[name].modules = [];
}
if (fillBind && !Array.isArray(config[name].bind)) {
config[name].bind = [];
}
}
fillComponent('udp', true);
fillComponent('tcp', true);
fillComponent('http', false);
fillComponent('tls', false);
if (!config.tls.acme && (args.email || args.agreeTos)) {
config.tls.acme = {};
}
if (typeof args.agreeTos === 'string') {
config.tls.acme.approvedDomains = args.agreeTos.split(',');
}
if (args.email) {
config.tls.acme.email = args.email;
}
config.device = { hostname: require('os').hostname() };
config.tunnel = args.tunnel || config.tunnel;
if (Array.isArray(config.tcp.bind) && config.tcp.bind.length) {
return PromiseA.resolve(config);
}
// We need to make sure we only check once, because even though our workers can
// all bind on the same port witout issue we cannot. This will lead to failure
// to determine which ports will work once the first worker starts.
if (!tcpProm) {
tcpProm = new PromiseA(function (resolve, reject) {
require('../lib/check-ports').checkTcpPorts(function (failed, bound) {
var result = Object.keys(bound).map(Number);
if (result.length > 0) {
resolve(result);
} else {
reject(failed);
}
});
});
}
return tcpProm.then(
function (bound) {
config.tcp.bind = bound;
return config;
}, function (failed) {
Object.keys(failed).forEach(function (key) {
console.log('[error bind]', key, failed[key].code);
});
return PromiseA.reject(new Error("could not bind to the default ports"));
});
}
function run(args) {
var workers = {};
var cachedConfig;
function updateConfig(config) {
fillConfig(config, args).then(function (config) {
cachedConfig = config;
console.log('changed config', config);
Object.keys(workers).forEach(function (key) {
workers[key].send(cachedConfig);
});
});
}
process.on('SIGHUP', function () {
configStorage.read().then(updateConfig).catch(function (err) {
console.error('error updating config after SIGHUP', err);
});
});
cluster.on('message', function (worker, message) {
if (message.type !== 'com.daplie.goldilocks/config') {
return;
}
configStorage.save(message.changes).then(updateConfig).catch(function (err) {
console.error('error changing config', err);
});
});
cluster.on('online', function (worker) {
console.log('[worker]', worker.id, 'online');
workers[worker.id] = worker;
// Worker is listening
worker.send(cachedConfig);
});
cluster.on('exit', function (worker) {
delete workers[worker.id];
cluster.fork();
});
createConfigStorage(args)
.then(function (config) {
return fillConfig(config, args);
})
.then(function (config) {
console.log('config.tcp.bind', config.tcp.bind);
cachedConfig = config;
// TODO spin up multiple workers
// TODO use greenlock-cluster
cluster.fork();
}).catch(function (err) {
console.error(err);
process.exit(1);
})
;
}
function readEnv(args) {
// TODO
try {
if (process.env.GOLDILOCKS_HOME) {
process.chdir(process.env.GOLDILOCKS_HOME);
}
} catch (err) {}
var env = {
tunnel: process.env.GOLDILOCKS_TUNNEL_TOKEN || process.env.GOLDILOCKS_TUNNEL && true
, email: process.env.GOLDILOCKS_EMAIL
, cwd: process.env.GOLDILOCKS_HOME || process.cwd()
, debug: process.env.GOLDILOCKS_DEBUG && true
};
run(Object.assign({}, env, args));
}
var program = require('commander');
program
.version(require('../package.json').version)
.option('--agree-tos [url1,url2]', "agree to all Terms of Service for Daplie, Let's Encrypt, etc (or specific URLs only)")
.option('-c --config <file>', 'Path to config file (Goldilocks.json or Goldilocks.yml) example: --config /etc/goldilocks/Goldilocks.json')
.option('--tunnel [token]', 'Turn tunnel on. This will enter interactive mode for login if no token is specified.')
.option('--email <email>', "(Re)set default email to use for Daplie, Let's Encrypt, ACME, etc.")
.option('--debug', "Enable debug output")
.parse(process.argv);
readEnv(program);