forked from coolaj86/goldilocks.js
		
	
		
			
				
	
	
		
			459 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			459 lines
		
	
	
		
			12 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 };
 | |
| 
 | |
|   // 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 <file>', '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);
 |