From 426795528687aa4ea9985104a73e24f61d1dc0c4 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 13 Apr 2017 17:42:37 -0600 Subject: [PATCH] switch over to commander --- bin/goldilocks.js | 726 ++++------------------------------------------ lib/goldilocks.js | 688 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 743 insertions(+), 674 deletions(-) create mode 100644 lib/goldilocks.js diff --git a/bin/goldilocks.js b/bin/goldilocks.js index bbb4be3..53a85f0 100755 --- a/bin/goldilocks.js +++ b/bin/goldilocks.js @@ -1,687 +1,67 @@ #!/usr/bin/env node 'use strict'; -//var PromiseA = global.Promise; -var PromiseA = require('bluebird'); -var tls = require('tls'); -var https = require('httpolyglot'); -var http = require('http'); -var path = require('path'); -var DDNS = require('ddns-cli'); -var httpPort = 80; -var httpsPort = 443; -var lrPort = 35729; -var portFallback = 8443; -var insecurePortFallback = 4080; - -function showError(err, port) { - if ('EACCES' === err.code) { - console.error(err); - console.warn("You do not have permission to use '" + port + "'."); - console.warn("You can probably fix that by running as Administrator or root."); - } - else if ('EADDRINUSE' === err.code) { - console.warn("Another server is already running on '" + port + "'."); - console.warn("You can probably fix that by rebooting your computer (or stopping it if you know what it is)."); - } -} - -function createInsecureServer(port, _delete_me_, opts) { - return new PromiseA(function (realResolve) { - var server = http.createServer(); - - function resolve() { - realResolve(server); - } - - server.on('error', function (err) { - if (opts.errorInsecurePort || opts.manualInsecurePort) { - showError(err, port); - process.exit(1); - return; - } - - opts.errorInsecurePort = err.toString(); - - return createInsecureServer(insecurePortFallback, null, opts).then(resolve); - }); - - server.on('request', opts.redirectApp); - - server.listen(port, function () { - opts.insecurePort = port; - resolve(); - }); - }); -} - -function createServer(port, _delete_me_, content, opts) { - function approveDomains(params, certs, cb) { - // This is where you check your database and associated - // email addresses with domains and agreements and such - var domains = params.domains; - //var p; - console.log('approveDomains'); - console.log(domains); - - - // The domains being approved for the first time are listed in opts.domains - // Certs being renewed are listed in certs.altnames - if (certs) { - params.domains = certs.altnames; - //p = PromiseA.resolve(); - } - else { - //params.email = opts.email; - if (!opts.agreeTos) { - console.error("You have not previously registered '" + domains + "' so you must specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); - process.exit(1); - return; - } - params.agreeTos = opts.agreeTos; - } - - // ddns.token(params.email, domains[0]) - params.email = opts.email; - params.refreshToken = opts.refreshToken; - params.challengeType = 'dns-01'; - params.cli = opts.argv; - - cb(null, { options: params, certs: certs }); - } - - return new PromiseA(function (realResolve) { - var app = require('../lib/app.js'); - var ipaddr = require('ipaddr.js'); - var addresses = []; - - Object.keys(opts.ifaces).forEach(function (ifacename) { - var iface = opts.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(); - }); - - var Oauth3 = require('oauth3-cli'); - var oauth3 = Oauth3.create({ device: { hostname: opts.device } }); - return Oauth3.Devices.one(oauth3).then(function (device) { - return Oauth3.Devices.all(oauth3).then(function (devices) { - return { devices: devices, device: device.device || device }; - }); - }).then(function (devices) { - devices.device.secret = undefined; - console.log('devices'); - console.log(devices); - var directive = { - global: opts.global - , sites: opts.sites - , defaults: opts.defaults - , cwd: process.cwd() - , ifaces: opts.ifaces - , addresses: addresses - , devices: devices.devices - , device: devices.device - , net: { - createConnection: function (opts, cb) { - // opts = { host, port, data - // , /*proprietary to tunneler*/ servername, remoteAddress, remoteFamily, remotePort - // , secure (tls already terminated by a proxy) } - // // http://stackoverflow.com/questions/10348906/how-to-know-if-a-request-is-http-or-https-in-node-js - // var packerStream = require('tunnel-packer').Stream; - // TODO here we will have the tls termination (or re-forward) - } - } - }; - var server; - var insecureServer; - - function resolve() { - realResolve({ - plainServer: insecureServer - , server: server - }); - } - - // returns an instance of node-letsencrypt with additional helper methods - var webrootPath = require('os').tmpdir(); - var leChallengeFs = require('le-challenge-fs').create({ webrootPath: webrootPath }); - //var leChallengeSni = require('le-challenge-sni').create({ webrootPath: webrootPath }); - var leChallengeDdns = require('le-challenge-ddns').create({ ttl: 1 }); - var lex = require('greenlock-express').create({ - // set to https://acme-v01.api.letsencrypt.org/directory in production - server: opts.debug ? 'staging' : 'https://acme-v01.api.letsencrypt.org/directory' - - // If you wish to replace the default plugins, you may do so here - // - , challenges: { - 'http-01': leChallengeFs - , 'tls-sni-01': leChallengeFs // leChallengeSni - , 'dns-01': leChallengeDdns - } - , challengeType: (opts.tunnel ? 'http-01' : 'dns-01') - , store: require('le-store-certbot').create({ - webrootPath: webrootPath - , configDir: path.join((opts.homedir || '~'), 'letsencrypt', 'etc') - , homedir: opts.homedir - }) - , webrootPath: webrootPath - - // You probably wouldn't need to replace the default sni handler - // See https://git.daplie.com/Daplie/le-sni-auto if you think you do - //, sni: require('le-sni-auto').create({}) - - , approveDomains: approveDomains - }); - - var secureContexts = { - 'localhost.daplie.me': null - }; - opts.httpsOptions.SNICallback = function (sni, cb ) { - var tlsOptions; - console.log('[https] sni', sni); - - // Static Certs - if (/.*localhost.*\.daplie\.me/.test(sni.toLowerCase())) { - // TODO implement - if (!secureContexts[sni]) { - tlsOptions = require('localhost.daplie.me-certificates').mergeTlsOptions(sni, {}); - } - if (tlsOptions) { - secureContexts[sni] = tls.createSecureContext(tlsOptions); - } - cb(null, secureContexts[sni]); - return; - } - - // Dynamic Certs - lex.httpsOptions.SNICallback(sni, cb); - }; - server = https.createServer(opts.httpsOptions); - - server.on('error', function (err) { - if (opts.errorPort || opts.manualPort) { - showError(err, port); - process.exit(1); - return; - } - - opts.errorPort = err.toString(); - - return createServer(portFallback, null, content, opts).then(resolve); - }); - - server.listen(port, function () { - opts.port = port; - opts.redirectOptions.port = port; - - if (opts.livereload) { - opts.lrPort = opts.lrPort || lrPort; - var livereload = require('livereload'); - var server2 = livereload.createServer({ - https: opts.httpsOptions - , port: opts.lrPort - , exclusions: [ 'node_modules' ] - }); - - console.info("[livereload] watching " + opts.pubdir); - console.warn("WARNING: If CPU usage spikes to 100% it's because too many files are being watched"); - // TODO create map of directories to watch from opts.sites and iterate over it - server2.watch(opts.pubdir); - } - - // if we haven't disabled insecure port - if ('false' !== opts.insecurePort) { - // and both ports are the default - if ((httpsPort === opts.port && httpPort === opts.insecurePort) - // or other case - || (httpPort !== opts.insecurePort && opts.port !== opts.insecurePort) - ) { - return createInsecureServer(opts.insecurePort, null, opts).then(function (_server) { - insecureServer = _server; - resolve(); - }); - } - } - - opts.insecurePort = opts.port; - resolve(); - return; - }); - - if ('function' === typeof app) { - app = app(directive); - } else if ('function' === typeof app.create) { - app = app.create(directive); - } - - server.on('request', function (req, res) { - console.log('[' + req.method + '] ' + req.url); - if (!req.socket.encrypted && !/\/\.well-known\/acme-challenge\//.test(req.url)) { - opts.redirectApp(req, res); - return; - } - - if ('function' === typeof app) { - app(req, res); - return; - } - - res.end('not ready'); - }); - - return PromiseA.resolve(app).then(function (_app) { - app = _app; - }); - }); - }); -} - -module.exports.createServer = createServer; - -function run() { - var defaultServername = 'localhost.daplie.me'; - var minimist = require('minimist'); - var argv = minimist(process.argv.slice(2)); - var port = parseInt(argv.p || argv.port || argv._[0], 10) || httpsPort; - var livereload = argv.livereload; - var defaultWebRoot = path.normalize(argv['default-web-root'] || argv.d || argv._[1] || '.'); - var assetsPath = path.join(__dirname, '..', 'packages', 'assets'); - var content = argv.c; - var letsencryptHost = argv['letsencrypt-certs']; - var yaml = require('js-yaml'); - var fs = PromiseA.promisifyAll(require('fs')); - var configFile = argv.c || argv.conf || argv.config; +function readConfigAndRun(args) { + var fs = require('fs'); + var path = require('path'); + var cwd = args.cwd || process.cwd(); + var text; + var filename; var config; - console.log('defaultWebRoot', defaultWebRoot); - try { - config = fs.readFileSync(configFile || 'Goldilocks.yml'); - } catch(e) { - if (configFile) { - console.error('Failed to read config:', e); - process.exit(1); - } - } - - if (config) { - try { - config = yaml.safeLoad(config); - } catch(e) { - console.error('Failed to parse config:', e); - process.exit(1); - } - } - - if (argv.V || argv.version || argv.v) { - if (argv.v) { - console.warn("flag -v is reserved for future use. Use -V or --version for version information."); - } - console.info('v' + require('../package.json').version); - return; - } - - argv.sites = argv.sites; - - // letsencrypt - var httpsOptions = require('localhost.daplie.me-certificates').merge({}); - var secureContext; - - var opts = { - agreeTos: argv.agreeTos || argv['agree-tos'] - , debug: argv.debug - , device: argv.device - , provider: (argv.provider && 'false' !== argv.provider) ? argv.provider : 'oauth3.org' - , email: argv.email - , httpsOptions: { - key: httpsOptions.key - , cert: httpsOptions.cert - //, ca: httpsOptions.ca - } - , homedir: argv.homedir - , argv: argv - }; - var peerCa; - var p; - - opts.PromiseA = PromiseA; - opts.httpsOptions.SNICallback = function (sni, cb) { - if (!secureContext) { - secureContext = tls.createSecureContext(opts.httpsOptions); - } - cb(null, secureContext); - return; - }; - - if (letsencryptHost) { - // TODO remove in v3.x (aka goldilocks) - argv.key = argv.key || '/etc/letsencrypt/live/' + letsencryptHost + '/privkey.pem'; - argv.cert = argv.cert || '/etc/letsencrypt/live/' + letsencryptHost + '/fullchain.pem'; - argv.root = argv.root || argv.chain || ''; - argv.sites = argv.sites || letsencryptHost; - argv['serve-root'] = argv['serve-root'] || argv['serve-chain']; - // argv[express-app] - } - - if (argv['serve-root'] && !argv.root) { - console.error("You must specify bath --root to use --serve-root"); - return; - } - - if (argv.key || argv.cert || argv.root) { - if (!argv.key || !argv.cert) { - console.error("You must specify bath --key and --cert, and optionally --root (required with serve-root)"); - return; - } - - if (!Array.isArray(argv.root)) { - argv.root = [argv.root]; - } - - opts.httpsOptions.key = fs.readFileSync(argv.key); - opts.httpsOptions.cert = fs.readFileSync(argv.cert); - - // turn multiple-cert pemfile into array of cert strings - peerCa = argv.root.reduce(function (roots, fullpath) { - if (!fs.existsSync(fullpath)) { - return roots; - } - - return roots.concat(fs.readFileSync(fullpath, 'ascii') - .split('-----END CERTIFICATE-----') - .filter(function (ca) { - return ca.trim(); - }).map(function (ca) { - return (ca + '-----END CERTIFICATE-----').trim(); - })); - }, []); - - // TODO * `--verify /path/to/root.pem` require peers to present certificates from said authority - if (argv.verify) { - opts.httpsOptions.ca = peerCa; - opts.httpsOptions.requestCert = true; - opts.httpsOptions.rejectUnauthorized = true; - } - - if (argv['serve-root']) { - content = peerCa.join('\r\n'); - } - } - - - opts.cwd = process.cwd(); - opts.sites = []; - opts.sites._map = {}; - - if (argv.sites) { - opts._externalHost = false; - argv.sites.split(',').map(function (name) { - var nameparts = name.split('|'); - var servername = nameparts.shift(); - var modules; - - opts._externalHost = opts._externalHost || !/(^|\.)localhost\./.test(servername); - // TODO allow reverse proxy - if (!opts.sites._map[servername]) { - opts.sites._map[servername] = { $id: servername, paths: [] }; - opts.sites._map[servername].paths._map = {}; - opts.sites.push(opts.sites._map[servername]); - } - - if (!nameparts.length) { - return; - } - - if (!opts.sites._map[servername].paths._map['/']) { - opts.sites._map[servername].paths._map['/'] = { $id: '/', modules: [] }; - opts.sites._map[servername].paths.push(opts.sites._map[servername].paths._map['/']); - } - - modules = opts.sites._map[servername].paths._map['/'].modules; - modules.push({ - $id: 'serve' - , paths: nameparts - }); - modules.push({ - $id: 'indexes' - , paths: nameparts - }); - }); - } - - opts.groups = []; - - // 'packages', 'assets', 'com.daplie.caddy' - opts.global = { - modules: [ // TODO uh-oh we've got a mixed bag of modules (various types), a true map - { $id: 'greenlock', email: opts.email, tos: opts.tos } - , { $id: 'rvpn', email: opts.email, tos: opts.tos } - , { $id: 'content', content: content } - , { $id: 'livereload', on: opts.livereload } - , { $id: 'app', path: opts.expressApp } - ] - , paths: [ - { $id: '/assets/', modules: [ { $id: 'serve', paths: [ assetsPath ] } ] } - // TODO figure this b out - , { $id: '/.well-known/', modules: [ - { $id: 'serve', paths: [ path.join(assetsPath, 'well-known') ] } - ] } - ] - }; - opts.defaults = { - modules: [] - , paths: [ - { $id: '/', modules: [ - { $id: 'serve', paths: [ defaultWebRoot ] } - , { $id: 'indexes', paths: [ defaultWebRoot ] } - ] } - ] - }; - opts.sites.push({ - // greenlock: {} - $id: 'localhost.alpha.daplie.me' - , paths: [ - { $id: '/', modules: [ - { $id: 'serve', paths: [ path.resolve(__dirname, '..', 'admin', 'public') ] } - ] } - , { $id: '/api/', modules: [ - { $id: 'app', path: path.join(__dirname, 'admin') } - ] } - ] - }); - opts.sites.push({ - $id: 'localhost.daplie.invalid' - , paths: [ - { $id: '/', modules: [ { $id: 'serve', paths: [ path.resolve(__dirname, '..', 'admin', 'public') ] } ] } - , { $id: '/api/', modules: [ { $id: 'app', path: path.join(__dirname, 'admin') } ] } - ] - }); - - // ifaces - opts.ifaces = require('../lib/local-ip.js').find(); - - // TODO use arrays in all things - opts._old_server_name = opts.sites[0].$id; - opts.pubdir = defaultWebRoot.replace(/(:hostname|:servername).*/, ''); - - if (argv.p || argv.port || argv._[0]) { - opts.manualPort = true; - } - if (argv.t || argv.tunnel) { - opts.tunnel = true; - } - if (argv.i || argv['insecure-port']) { - opts.manualInsecurePort = true; - } - opts.insecurePort = parseInt(argv.i || argv['insecure-port'], 10) - || argv.i || argv['insecure-port'] - || httpPort - ; - opts.livereload = livereload; - - if (argv['express-app']) { - opts.expressApp = require(argv['express-app']); - } - - if (opts.email || opts._externalHost) { - if (!opts.agreeTos) { - console.warn("You may need to specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); - } - if (!opts.email) { - // TODO store email in .ddnsrc.json - console.warn("You may need to specify --email to register with both the Let's Encrypt and Daplie DNS."); - } - p = DDNS.refreshToken({ - email: opts.email - , providerUrl: opts.provider - , silent: true - , homedir: opts.homedir - }, { - debug: false - , email: opts.argv.email - }).then(function (refreshToken) { - opts.refreshToken = refreshToken; - }); + if (args.config) { + text = fs.readFileSync(path.join(cwd, args.config), 'utf8'); } else { - p = PromiseA.resolve(); + filename = path.join(cwd, 'Goldilocks.yml'); + + if (fs.existsSync(filename)) { + text = fs.readFileSync(filename, 'utf8'); + } + else { + filename = path.join(cwd, 'Goldilocks.json'); + if (fs.existsSync(filename)) { + text = fs.readFileSync(filename, 'utf8'); + } else { + text = '{}'; + } + } } - return p.then(function () { - - // can be changed to tunnel external port - opts.redirectOptions = { - port: opts.port - }; - opts.redirectApp = require('redirect-https')(opts.redirectOptions); - - return createServer(port, null, content, opts).then(function (servers) { - var p; - var httpsUrl; - var httpUrl; - var promise; - - // TODO show all sites - console.info(''); - console.info('Serving ' + opts.pubdir + ' at '); - console.info(''); - - // Port - httpsUrl = 'https://' + opts._old_server_name; - p = opts.port; - if (httpsPort !== p) { - httpsUrl += ':' + p; + try { + config = JSON.parse(text); + } catch(e) { + try { + config = require('js-yaml').safeLoad(text); + } catch(e) { + throw new Error( + "Could not load '" + filename + "' as JSON nor YAML" + ); } - console.info('\t' + httpsUrl); + } - // Insecure Port - httpUrl = 'http://' + opts._old_server_name; - p = opts.insecurePort; - if (httpPort !== p) { - httpUrl += ':' + p; - } - console.info('\t' + httpUrl + ' (redirecting to https)'); - console.info(''); - - if (!(argv.sites && (defaultServername !== argv.sites) && !(argv.key && argv.cert))) { - // TODO what is this condition actually intending to test again? - // (I think it can be replaced with if (!opts._externalHost) { ... } - - promise = PromiseA.resolve(); - } else { - console.info("Attempting to resolve external connection for '" + opts._old_server_name + "'"); - try { - promise = require('../lib/match-ips.js').match(opts._old_server_name, opts); - } catch(e) { - console.warn("Upgrade to version 2.x to use automatic certificate issuance for '" + opts._old_server_name + "'"); - promise = PromiseA.resolve(); - } - } - - return promise.then(function (matchingIps) { - if (matchingIps) { - if (!matchingIps.length) { - console.info("Neither the attached nor external interfaces match '" + opts._old_server_name + "'"); - } - } - opts.matchingIps = matchingIps || []; - - if (opts.matchingIps.length) { - console.info(''); - console.info('External IPs:'); - console.info(''); - opts.matchingIps.forEach(function (ip) { - if ('IPv4' === ip.family) { - httpsUrl = 'https://' + ip.address; - if (httpsPort !== opts.port) { - httpsUrl += ':' + opts.port; - } - console.info('\t' + httpsUrl); - } - else { - httpsUrl = 'https://[' + ip.address + ']'; - if (httpsPort !== opts.port) { - httpsUrl += ':' + opts.port; - } - console.info('\t' + httpsUrl); - } - }); - } - else if (!opts.tunnel) { - console.info("External IP address does not match local IP address."); - console.info("Use --tunnel to allow the people of the Internet to access your server."); - } - - if (opts.tunnel) { - require('../lib/tunnel.js').create(opts, servers); - } - else if (opts.ddns) { - require('../lib/ddns.js').create(opts, servers); - } - - Object.keys(opts.ifaces).forEach(function (iname) { - var iface = opts.ifaces[iname]; - - if (iface.ipv4.length) { - console.info(''); - console.info(iname + ':'); - - httpsUrl = 'https://' + iface.ipv4[0].address; - if (httpsPort !== opts.port) { - httpsUrl += ':' + opts.port; - } - console.info('\t' + httpsUrl); - - if (iface.ipv6.length) { - httpsUrl = 'https://[' + iface.ipv6[0].address + ']'; - if (httpsPort !== opts.port) { - httpsUrl += ':' + opts.port; - } - console.info('\t' + httpsUrl); - } - } - }); - - console.info(''); - }); - }); - }); + require('../lib/goldilocks.js').create(config); } -if (require.main === module) { - run(); +if (process.argv.length === 2) { + readConfigAndRun({}); +} +else if (process.argv.length === 4) { + if ('-c' === process.argv[3] || '--config' === process.argv[3]) { + readConfigAndRun({ config: process.argv[4] }); + } +} +else if (process.argv.length > 2) { + var program = require('commander'); + + program + .version(require('package.json').version) + .option('--config', '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.') + .parse(process.argv); + + readConfigAndRun(program); +} +else { + throw new Error("impossible number of arguments: " + process.argv.length); } diff --git a/lib/goldilocks.js b/lib/goldilocks.js new file mode 100644 index 0000000..bde7a88 --- /dev/null +++ b/lib/goldilocks.js @@ -0,0 +1,688 @@ +'use strict'; + +module.exports.create = function (config) { + //var PromiseA = global.Promise; + var PromiseA = require('bluebird'); + var tls = require('tls'); + var https = require('httpolyglot'); + var http = require('http'); + var path = require('path'); + var httpPort = 80; + var httpsPort = 443; + var lrPort = 35729; + var portFallback = 8443; + var insecurePortFallback = 4080; + + function showError(err, port) { + if ('EACCES' === err.code) { + console.error(err); + console.warn("You do not have permission to use '" + port + "'."); + console.warn("You can probably fix that by running as Administrator or root."); + } + else if ('EADDRINUSE' === err.code) { + console.warn("Another server is already running on '" + port + "'."); + console.warn("You can probably fix that by rebooting your computer (or stopping it if you know what it is)."); + } + } + + function createInsecureServer(port, _delete_me_, opts) { + return new PromiseA(function (realResolve) { + var server = http.createServer(); + + function resolve() { + realResolve(server); + } + + server.on('error', function (err) { + if (opts.errorInsecurePort || opts.manualInsecurePort) { + showError(err, port); + process.exit(1); + return; + } + + opts.errorInsecurePort = err.toString(); + + return createInsecureServer(insecurePortFallback, null, opts).then(resolve); + }); + + server.on('request', opts.redirectApp); + + server.listen(port, function () { + opts.insecurePort = port; + resolve(); + }); + }); + } + + function createServer(port, _delete_me_, content, opts) { + function approveDomains(params, certs, cb) { + // This is where you check your database and associated + // email addresses with domains and agreements and such + var domains = params.domains; + //var p; + console.log('approveDomains'); + console.log(domains); + + + // The domains being approved for the first time are listed in opts.domains + // Certs being renewed are listed in certs.altnames + if (certs) { + params.domains = certs.altnames; + //p = PromiseA.resolve(); + } + else { + //params.email = opts.email; + if (!opts.agreeTos) { + console.error("You have not previously registered '" + domains + "' so you must specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); + process.exit(1); + return; + } + params.agreeTos = opts.agreeTos; + } + + // ddns.token(params.email, domains[0]) + params.email = opts.email; + params.refreshToken = opts.refreshToken; + params.challengeType = 'dns-01'; + params.cli = opts.argv; + + cb(null, { options: params, certs: certs }); + } + + return new PromiseA(function (realResolve) { + var app = require('../lib/app.js'); + var ipaddr = require('ipaddr.js'); + var addresses = []; + + Object.keys(opts.ifaces).forEach(function (ifacename) { + var iface = opts.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(); + }); + + var Oauth3 = require('oauth3-cli'); + var oauth3 = Oauth3.create({ device: { hostname: opts.device } }); + return Oauth3.Devices.one(oauth3).then(function (device) { + return Oauth3.Devices.all(oauth3).then(function (devices) { + return { devices: devices, device: device.device || device }; + }); + }).then(function (devices) { + devices.device.secret = undefined; + console.log('devices'); + console.log(devices); + var directive = { + global: opts.global + , sites: opts.sites + , defaults: opts.defaults + , cwd: process.cwd() + , ifaces: opts.ifaces + , addresses: addresses + , devices: devices.devices + , device: devices.device + , net: { + createConnection: function (opts, cb) { + // opts = { host, port, data + // , /*proprietary to tunneler*/ servername, remoteAddress, remoteFamily, remotePort + // , secure (tls already terminated by a proxy) } + // // http://stackoverflow.com/questions/10348906/how-to-know-if-a-request-is-http-or-https-in-node-js + // var packerStream = require('tunnel-packer').Stream; + // TODO here we will have the tls termination (or re-forward) + return require('net').createConnection(opts, cb); + } + } + }; + var server; + var insecureServer; + + function resolve() { + realResolve({ + plainServer: insecureServer + , server: server + }); + } + + // returns an instance of node-letsencrypt with additional helper methods + var webrootPath = require('os').tmpdir(); + var leChallengeFs = require('le-challenge-fs').create({ webrootPath: webrootPath }); + //var leChallengeSni = require('le-challenge-sni').create({ webrootPath: webrootPath }); + var leChallengeDdns = require('le-challenge-ddns').create({ ttl: 1 }); + var lex = require('greenlock-express').create({ + // set to https://acme-v01.api.letsencrypt.org/directory in production + server: opts.debug ? 'staging' : 'https://acme-v01.api.letsencrypt.org/directory' + + // If you wish to replace the default plugins, you may do so here + // + , challenges: { + 'http-01': leChallengeFs + , 'tls-sni-01': leChallengeFs // leChallengeSni + , 'dns-01': leChallengeDdns + } + , challengeType: (opts.tunnel ? 'http-01' : 'dns-01') + , store: require('le-store-certbot').create({ + webrootPath: webrootPath + , configDir: path.join((opts.homedir || '~'), 'letsencrypt', 'etc') + , homedir: opts.homedir + }) + , webrootPath: webrootPath + + // You probably wouldn't need to replace the default sni handler + // See https://git.daplie.com/Daplie/le-sni-auto if you think you do + //, sni: require('le-sni-auto').create({}) + + , approveDomains: approveDomains + }); + + var secureContexts = { + 'localhost.daplie.me': null + }; + opts.httpsOptions.SNICallback = function (sni, cb ) { + var tlsOptions; + console.log('[https] sni', sni); + + // Static Certs + if (/.*localhost.*\.daplie\.me/.test(sni.toLowerCase())) { + // TODO implement + if (!secureContexts[sni]) { + tlsOptions = require('localhost.daplie.me-certificates').mergeTlsOptions(sni, {}); + } + if (tlsOptions) { + secureContexts[sni] = tls.createSecureContext(tlsOptions); + } + cb(null, secureContexts[sni]); + return; + } + + // Dynamic Certs + lex.httpsOptions.SNICallback(sni, cb); + }; + server = https.createServer(opts.httpsOptions); + + server.on('error', function (err) { + if (opts.errorPort || opts.manualPort) { + showError(err, port); + process.exit(1); + return; + } + + opts.errorPort = err.toString(); + + return createServer(portFallback, null, content, opts).then(resolve); + }); + + server.listen(port, function () { + opts.port = port; + opts.redirectOptions.port = port; + + if (opts.livereload) { + opts.lrPort = opts.lrPort || lrPort; + var livereload = require('livereload'); + var server2 = livereload.createServer({ + https: opts.httpsOptions + , port: opts.lrPort + , exclusions: [ 'node_modules' ] + }); + + console.info("[livereload] watching " + opts.pubdir); + console.warn("WARNING: If CPU usage spikes to 100% it's because too many files are being watched"); + // TODO create map of directories to watch from opts.sites and iterate over it + server2.watch(opts.pubdir); + } + + // if we haven't disabled insecure port + if ('false' !== opts.insecurePort) { + // and both ports are the default + if ((httpsPort === opts.port && httpPort === opts.insecurePort) + // or other case + || (httpPort !== opts.insecurePort && opts.port !== opts.insecurePort) + ) { + return createInsecureServer(opts.insecurePort, null, opts).then(function (_server) { + insecureServer = _server; + resolve(); + }); + } + } + + opts.insecurePort = opts.port; + resolve(); + return; + }); + + if ('function' === typeof app) { + app = app(directive); + } else if ('function' === typeof app.create) { + app = app.create(directive); + } + + server.on('request', function (req, res) { + console.log('[' + req.method + '] ' + req.url); + if (!req.socket.encrypted && !/\/\.well-known\/acme-challenge\//.test(req.url)) { + opts.redirectApp(req, res); + return; + } + + if ('function' === typeof app) { + app(req, res); + return; + } + + res.end('not ready'); + }); + + return PromiseA.resolve(app).then(function (_app) { + app = _app; + }); + }); + }); + } + + module.exports.createServer = createServer; + + function run() { + var defaultServername = 'localhost.daplie.me'; + var minimist = require('minimist'); + var argv = minimist(process.argv.slice(2)); + var port = parseInt(argv.p || argv.port || argv._[0], 10) || httpsPort; + var livereload = argv.livereload; + var defaultWebRoot = path.normalize(argv['default-web-root'] || argv.d || argv._[1] || '.'); + var assetsPath = path.join(__dirname, '..', 'packages', 'assets'); + var content = argv.c; + var letsencryptHost = argv['letsencrypt-certs']; + var yaml = require('js-yaml'); + var fs = PromiseA.promisifyAll(require('fs')); + var configFile = argv.c || argv.conf || argv.config; + var config; + var DDNS; + console.log('defaultWebRoot', defaultWebRoot); + + try { + config = fs.readFileSync(configFile || 'Goldilocks.yml'); + } catch(e) { + if (configFile) { + console.error('Failed to read config:', e); + process.exit(1); + } + } + + if (config) { + try { + config = yaml.safeLoad(config); + } catch(e) { + console.error('Failed to parse config:', e); + process.exit(1); + } + } + + if (argv.V || argv.version || argv.v) { + if (argv.v) { + console.warn("flag -v is reserved for future use. Use -V or --version for version information."); + } + console.info('v' + require('../package.json').version); + return; + } + + argv.sites = argv.sites; + + // letsencrypt + var httpsOptions = require('localhost.daplie.me-certificates').merge({}); + var secureContext; + + var opts = { + agreeTos: argv.agreeTos || argv['agree-tos'] + , debug: argv.debug + , device: argv.device + , provider: (argv.provider && 'false' !== argv.provider) ? argv.provider : 'oauth3.org' + , email: argv.email + , httpsOptions: { + key: httpsOptions.key + , cert: httpsOptions.cert + //, ca: httpsOptions.ca + } + , homedir: argv.homedir + , argv: argv + }; + var peerCa; + var p; + + opts.PromiseA = PromiseA; + opts.httpsOptions.SNICallback = function (sni, cb) { + if (!secureContext) { + secureContext = tls.createSecureContext(opts.httpsOptions); + } + cb(null, secureContext); + return; + }; + + if (letsencryptHost) { + // TODO remove in v3.x (aka goldilocks) + argv.key = argv.key || '/etc/letsencrypt/live/' + letsencryptHost + '/privkey.pem'; + argv.cert = argv.cert || '/etc/letsencrypt/live/' + letsencryptHost + '/fullchain.pem'; + argv.root = argv.root || argv.chain || ''; + argv.sites = argv.sites || letsencryptHost; + argv['serve-root'] = argv['serve-root'] || argv['serve-chain']; + // argv[express-app] + } + + if (argv['serve-root'] && !argv.root) { + console.error("You must specify bath --root to use --serve-root"); + return; + } + + if (argv.key || argv.cert || argv.root) { + if (!argv.key || !argv.cert) { + console.error("You must specify bath --key and --cert, and optionally --root (required with serve-root)"); + return; + } + + if (!Array.isArray(argv.root)) { + argv.root = [argv.root]; + } + + opts.httpsOptions.key = fs.readFileSync(argv.key); + opts.httpsOptions.cert = fs.readFileSync(argv.cert); + + // turn multiple-cert pemfile into array of cert strings + peerCa = argv.root.reduce(function (roots, fullpath) { + if (!fs.existsSync(fullpath)) { + return roots; + } + + return roots.concat(fs.readFileSync(fullpath, 'ascii') + .split('-----END CERTIFICATE-----') + .filter(function (ca) { + return ca.trim(); + }).map(function (ca) { + return (ca + '-----END CERTIFICATE-----').trim(); + })); + }, []); + + // TODO * `--verify /path/to/root.pem` require peers to present certificates from said authority + if (argv.verify) { + opts.httpsOptions.ca = peerCa; + opts.httpsOptions.requestCert = true; + opts.httpsOptions.rejectUnauthorized = true; + } + + if (argv['serve-root']) { + content = peerCa.join('\r\n'); + } + } + + + opts.cwd = process.cwd(); + opts.sites = []; + opts.sites._map = {}; + + if (argv.sites) { + opts._externalHost = false; + argv.sites.split(',').map(function (name) { + var nameparts = name.split('|'); + var servername = nameparts.shift(); + var modules; + + opts._externalHost = opts._externalHost || !/(^|\.)localhost\./.test(servername); + // TODO allow reverse proxy + if (!opts.sites._map[servername]) { + opts.sites._map[servername] = { $id: servername, paths: [] }; + opts.sites._map[servername].paths._map = {}; + opts.sites.push(opts.sites._map[servername]); + } + + if (!nameparts.length) { + return; + } + + if (!opts.sites._map[servername].paths._map['/']) { + opts.sites._map[servername].paths._map['/'] = { $id: '/', modules: [] }; + opts.sites._map[servername].paths.push(opts.sites._map[servername].paths._map['/']); + } + + modules = opts.sites._map[servername].paths._map['/'].modules; + modules.push({ + $id: 'serve' + , paths: nameparts + }); + modules.push({ + $id: 'indexes' + , paths: nameparts + }); + }); + } + + opts.groups = []; + + // 'packages', 'assets', 'com.daplie.caddy' + opts.global = { + modules: [ // TODO uh-oh we've got a mixed bag of modules (various types), a true map + { $id: 'greenlock', email: opts.email, tos: opts.tos } + , { $id: 'rvpn', email: opts.email, tos: opts.tos } + , { $id: 'content', content: content } + , { $id: 'livereload', on: opts.livereload } + , { $id: 'app', path: opts.expressApp } + ] + , paths: [ + { $id: '/assets/', modules: [ { $id: 'serve', paths: [ assetsPath ] } ] } + // TODO figure this b out + , { $id: '/.well-known/', modules: [ + { $id: 'serve', paths: [ path.join(assetsPath, 'well-known') ] } + ] } + ] + }; + opts.defaults = { + modules: [] + , paths: [ + { $id: '/', modules: [ + { $id: 'serve', paths: [ defaultWebRoot ] } + , { $id: 'indexes', paths: [ defaultWebRoot ] } + ] } + ] + }; + opts.sites.push({ + // greenlock: {} + $id: 'localhost.alpha.daplie.me' + , paths: [ + { $id: '/', modules: [ + { $id: 'serve', paths: [ path.resolve(__dirname, '..', 'admin', 'public') ] } + ] } + , { $id: '/api/', modules: [ + { $id: 'app', path: path.join(__dirname, 'admin') } + ] } + ] + }); + opts.sites.push({ + $id: 'localhost.daplie.invalid' + , paths: [ + { $id: '/', modules: [ { $id: 'serve', paths: [ path.resolve(__dirname, '..', 'admin', 'public') ] } ] } + , { $id: '/api/', modules: [ { $id: 'app', path: path.join(__dirname, 'admin') } ] } + ] + }); + + // ifaces + opts.ifaces = require('../lib/local-ip.js').find(); + + // TODO use arrays in all things + opts._old_server_name = opts.sites[0].$id; + opts.pubdir = defaultWebRoot.replace(/(:hostname|:servername).*/, ''); + + if (argv.p || argv.port || argv._[0]) { + opts.manualPort = true; + } + if (argv.t || argv.tunnel) { + opts.tunnel = true; + } + if (argv.i || argv['insecure-port']) { + opts.manualInsecurePort = true; + } + opts.insecurePort = parseInt(argv.i || argv['insecure-port'], 10) + || argv.i || argv['insecure-port'] + || httpPort + ; + opts.livereload = livereload; + + if (argv['express-app']) { + opts.expressApp = require(argv['express-app']); + } + + if (opts.email || opts._externalHost) { + if (!opts.agreeTos) { + console.warn("You may need to specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); + } + if (!opts.email) { + // TODO store email in .ddnsrc.json + console.warn("You may need to specify --email to register with both the Let's Encrypt and Daplie DNS."); + } + DDNS = require('ddns-cli'); + p = DDNS.refreshToken({ + email: opts.email + , providerUrl: opts.provider + , silent: true + , homedir: opts.homedir + }, { + debug: false + , email: opts.argv.email + }).then(function (refreshToken) { + opts.refreshToken = refreshToken; + }); + } + else { + p = PromiseA.resolve(); + } + + return p.then(function () { + + // can be changed to tunnel external port + opts.redirectOptions = { + port: opts.port + }; + opts.redirectApp = require('redirect-https')(opts.redirectOptions); + + return createServer(port, null, content, opts).then(function (servers) { + var p; + var httpsUrl; + var httpUrl; + var promise; + + // TODO show all sites + console.info(''); + console.info('Serving ' + opts.pubdir + ' at '); + console.info(''); + + // Port + httpsUrl = 'https://' + opts._old_server_name; + p = opts.port; + if (httpsPort !== p) { + httpsUrl += ':' + p; + } + console.info('\t' + httpsUrl); + + // Insecure Port + httpUrl = 'http://' + opts._old_server_name; + p = opts.insecurePort; + if (httpPort !== p) { + httpUrl += ':' + p; + } + console.info('\t' + httpUrl + ' (redirecting to https)'); + console.info(''); + + if (!(argv.sites && (defaultServername !== argv.sites) && !(argv.key && argv.cert))) { + // TODO what is this condition actually intending to test again? + // (I think it can be replaced with if (!opts._externalHost) { ... } + + promise = PromiseA.resolve(); + } else { + console.info("Attempting to resolve external connection for '" + opts._old_server_name + "'"); + try { + promise = require('../lib/match-ips.js').match(opts._old_server_name, opts); + } catch(e) { + console.warn("Upgrade to version 2.x to use automatic certificate issuance for '" + opts._old_server_name + "'"); + promise = PromiseA.resolve(); + } + } + + return promise.then(function (matchingIps) { + if (matchingIps) { + if (!matchingIps.length) { + console.info("Neither the attached nor external interfaces match '" + opts._old_server_name + "'"); + } + } + opts.matchingIps = matchingIps || []; + + if (opts.matchingIps.length) { + console.info(''); + console.info('External IPs:'); + console.info(''); + opts.matchingIps.forEach(function (ip) { + if ('IPv4' === ip.family) { + httpsUrl = 'https://' + ip.address; + if (httpsPort !== opts.port) { + httpsUrl += ':' + opts.port; + } + console.info('\t' + httpsUrl); + } + else { + httpsUrl = 'https://[' + ip.address + ']'; + if (httpsPort !== opts.port) { + httpsUrl += ':' + opts.port; + } + console.info('\t' + httpsUrl); + } + }); + } + else if (!opts.tunnel) { + console.info("External IP address does not match local IP address."); + console.info("Use --tunnel to allow the people of the Internet to access your server."); + } + + if (opts.tunnel) { + require('../lib/tunnel.js').create(opts, servers); + } + else if (opts.ddns) { + require('../lib/ddns.js').create(opts, servers); + } + + Object.keys(opts.ifaces).forEach(function (iname) { + var iface = opts.ifaces[iname]; + + if (iface.ipv4.length) { + console.info(''); + console.info(iname + ':'); + + httpsUrl = 'https://' + iface.ipv4[0].address; + if (httpsPort !== opts.port) { + httpsUrl += ':' + opts.port; + } + console.info('\t' + httpsUrl); + + if (iface.ipv6.length) { + httpsUrl = 'https://[' + iface.ipv6[0].address + ']'; + if (httpsPort !== opts.port) { + httpsUrl += ':' + opts.port; + } + console.info('\t' + httpsUrl); + } + } + }); + + console.info(''); + }); + }); + }); + } + + run(); +}; diff --git a/package.json b/package.json index 058c171..23fb45d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "bluebird": "^3.4.6", "body-parser": "git+https://github.com/expressjs/body-parser.git#1.16.1", + "commander": "^2.9.0", "daplie-tunnel": "git+https://git.daplie.com/Daplie/daplie-cli-tunnel.git#master", "ddns-cli": "git+https://git.daplie.com/Daplie/node-ddns-client.git#master", "express": "git+https://github.com/expressjs/express.git#4.x", @@ -49,7 +50,7 @@ "httpolyglot": "^0.1.1", "ipaddr.js": "git+https://github.com/whitequark/ipaddr.js.git#v1.3.0", "ipify": "^1.1.0", - "js-yaml": "^3.8.1", + "js-yaml": "^3.8.3", "le-challenge-ddns": "git+https://git.daplie.com/Daplie/le-challenge-ddns.git#master", "le-challenge-fs": "git+https://git.daplie.com/Daplie/le-challenge-webroot.git#master", "le-challenge-sni": "^2.0.1",