goldilocks.js/bin/goldilocks.js

330 lines
9.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
'use strict';
var cluster = require('cluster');
if (!cluster.isMaster) {
require('../lib/worker.js');
return;
}
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 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;
}
function read() {
return fs.readFileAsync(filename)
.catch(function (err) {
if (err.code === 'ENOENT') {
return '';
}
return PromiseA.reject(err);
})
.then(parse)
;
}
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;
}
function checkConfigLocation(cwd, configFile) {
cwd = cwd || process.cwd();
var path = require('path');
var filename;
var prom;
if (configFile) {
filename = path.resolve(cwd, configFile);
prom = fs.readFileAsync(filename)
.catch(function (err) {
if (err.code !== 'ENOENT') {
return PromiseA.reject(err);
}
if (path.extname(filename) === '.json') {
return '{}';
}
return '';
})
;
} else {
prom = PromiseA.reject('blah')
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.yml');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.json');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'etc/goldilocks/goldilocks.yml');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = '/etc/goldilocks/goldilocks.yml';
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.yml');
return '';
})
;
}
return prom.then(function (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');
});
}
function createConfigStorage(args) {
return checkConfigLocation(args.cwd, args.config)
.then(function (result) {
console.log('config file', result.name, 'is of type', result.type);
configStorage = createStorage(result.name, result.type);
return configStorage.read();
})
;
}
var tcpProm;
function fillConfig(config, args) {
config.debug = config.debug || args.debug;
if (!config.dns) {
config.dns = { bind: [ 53 ], modules: [{ name: 'proxy', port: 3053 }] };
}
// Use Object.assign to add any properties needed but not defined in the mdns config.
// It will first copy the defaults into an empty object, then copy any real config over that.
var mdnsDefaults = { port: 5353, broadcast: '224.0.0.251', ttl: 300 };
config.mdns = Object.assign({}, mdnsDefaults, config.mdns);
if (!config.tcp) {
config.tcp = {};
}
if (!config.http) {
config.http = { modules: [{ name: 'proxy', domains: ['*'], port: 3000 }] };
}
if (!config.tls) {
config.tls = {};
}
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.email = args.email;
config.tls.acme.email = args.email;
}
// maybe this should not go in config... but be ephemeral in some way?
config.cwd = args.cwd || config.cwd || process.cwd();
var ipaddr = require('ipaddr.js');
var addresses = [];
var ifaces = require('../lib/local-ip.js').find();
Object.keys(ifaces).forEach(function (ifacename) {
var iface = ifaces[ifacename];
iface.ipv4.forEach(function (ip) {
addresses.push(ip);
});
iface.ipv6.forEach(function (ip) {
addresses.push(ip);
});
});
addresses.sort(function (a, b) {
if (a.family !== b.family) {
return 'IPv4' === a.family ? 1 : -1;
}
return a.address > b.address ? 1 : -1;
});
addresses.forEach(function (addr) {
addr.range = ipaddr.parse(addr.address).range();
});
// TODO maybe move to config.state.addresses (?)
config.addresses = addresses;
config.device = { hostname: require('os').hostname() };
config.tunnel = args.tunnel || config.tunnel;
if (Array.isArray(config.tcp.bind)) {
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;
cluster.on('message', function (worker, message) {
if (message.type !== 'com.daplie.goldilocks.config-change') {
return;
}
configStorage.save(message.changes)
.then(function (config) {
return fillConfig(config, args);
})
.then(function (config) {
cachedConfig = config;
console.log('changed config', config);
Object.keys(workers).forEach(function (key) {
workers[key].send(cachedConfig);
});
})
.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();
})
;
}
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);