463 lines
13 KiB
JavaScript
Executable File
463 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);
|
|
|
|
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
|
|
, 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('-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('--debug', "Enable debug output")
|
|
.parse(process.argv);
|
|
|
|
readEnv(program);
|