#!/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 }; // 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); fillComponent('ddns', false); config.device = { hostname: require('os').hostname() }; 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 = { 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 ', 'Path to config file (Goldilocks.json or Goldilocks.yml) example: --config /etc/goldilocks/Goldilocks.json') .option('--debug', "Enable debug output") .parse(process.argv); readEnv(program);