diff --git a/app.js b/app.js index bd05672..505c824 100644 --- a/app.js +++ b/app.js @@ -21,7 +21,7 @@ module.exports = function (opts) { if (opts.livereload) { livereload = ''; + + ':' + opts.lrPort + '/livereload.js?snipver=1">'; addLen = livereload.length; } diff --git a/match-ips.js b/match-ips.js new file mode 100644 index 0000000..061e2d4 --- /dev/null +++ b/match-ips.js @@ -0,0 +1,107 @@ +'use strict'; + +var PromiseA = require('bluebird'); + +module.exports.match = function (servername, opts) { + return PromiseA.promisify(require('ipify'))().then(function (ip) { + var dns = PromiseA.promisifyAll(require('dns')); + + opts.externalIps = [ { address: ip, family: 'IPv4' } ]; + opts.ifaces = require('./local-ip.js').find({ externals: opts.externalIps }); + opts.externalIfaces = Object.keys(opts.ifaces).reduce(function (all, iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + iface.ipv6.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + + return all; + }, []).filter(Boolean); + + function resolveIps(hostname) { + var allIps = []; + + return PromiseA.all([ + dns.resolve4Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv4' + }); + }); + }, function () {}) + , dns.resolve6Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv6' + }); + }); + }, function () {}) + ]).then(function () { + return allIps; + }); + } + + function resolveIpsAndCnames(hostname) { + return PromiseA.all([ + resolveIps(hostname) + , dns.resolveCnameAsync(hostname).then(function (records) { + return PromiseA.all(records.map(function (hostname) { + return resolveIps(hostname); + })).then(function (allIps) { + return allIps.reduce(function (all, ips) { + return all.concat(ips); + }, []); + }); + }, function () { + return []; + }) + ]).then(function (ips) { + return ips.reduce(function (all, set) { + return all.concat(set); + }, []); + }); + } + + return resolveIpsAndCnames(servername).then(function (allIps) { + var matchingIps = []; + + if (!allIps.length) { + console.warn("Could not resolve '" + servername + "'"); + } + + // { address, family } + allIps.some(function (ip) { + function match(addr) { + if (ip.address === addr.address) { + matchingIps.push(addr); + } + } + + opts.externalIps.forEach(match); + // opts.externalIfaces.forEach(match); + + Object.keys(opts.ifaces).forEach(function (iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(match); + iface.ipv6.forEach(match); + }); + + return matchingIps.length; + }); + + return matchingIps; + }); + }); +}; diff --git a/package.json b/package.json index c94d28a..9ab5141 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serve-https", - "version": "1.5.6", + "version": "2.0.0", "description": "Serves HTTPS using TLS (SSL) certs for localhost.daplie.com - great for testing and development.", "main": "serve.js", "scripts": { @@ -38,8 +38,12 @@ }, "homepage": "https://github.com/Daplie/serve-https#readme", "dependencies": { + "bluebird": "^3.4.6", "finalhandler": "^0.4.0", "ipify": "^1.1.0", + "le-challenge-dns": "^2.0.1", + "le-challenge-fs": "^2.0.5", + "letsencrypt-express": "^2.0.2", "livereload": "^0.4.0", "localhost.daplie.com-certificates": "^1.2.0", "minimist": "^1.1.1", diff --git a/serve.js b/serve.js index 0cfe08c..e4cb91a 100755 --- a/serve.js +++ b/serve.js @@ -6,6 +6,7 @@ var https = require('https'); var http = require('http'); var fs = require('fs'); var path = require('path'); +var DDNS = require('ddns-cli'); var portFallback = 8443; var insecurePortFallback = 4080; @@ -49,13 +50,74 @@ function createInsecureServer(port, pubdir, opts) { } function createServer(port, pubdir, 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 (resolve) { - var server = https.createServer(opts); var app = require('./app'); var directive = { public: pubdir, content: content, livereload: opts.livereload , servername: opts.servername, expressApp: opts.expressApp }; + // 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 leChallengeDns = require('le-challenge-dns').create({ ttl: 1 }); + var lex = require('letsencrypt-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 + , 'dns-01': leChallengeDns + } + , challengeType: 'dns-01' + , store: require('le-store-certbot').create({ webrootPath: webrootPath }) + , webrootPath: webrootPath + + // You probably wouldn't need to replace the default sni handler + // See https://github.com/Daplie/le-sni-auto if you think you do + //, sni: require('le-sni-auto').create({}) + + , approveDomains: approveDomains + }); + opts.httpsOptions.SNICallback = lex.httpsOptions.SNICallback; + var server = https.createServer(opts.httpsOptions); + server.on('error', function (err) { if (opts.errorPort || opts.manualPort) { showError(err, port); @@ -71,8 +133,9 @@ function createServer(port, pubdir, content, opts) { server.listen(port, function () { opts.port = port; + opts.lrPort = 35729; var livereload = require('livereload'); - var server2 = livereload.createServer({ https: opts }); + var server2 = livereload.createServer({ https: opts.httpsOptions, port: opts.lrPort }); server2.watch(pubdir); @@ -90,6 +153,8 @@ function createServer(port, pubdir, content, opts) { } server.on('request', function (req, res) { + console.log('[' + req.method + '] ' + req.url); + if ('function' === typeof app) { app(req, res); return; @@ -118,22 +183,23 @@ function run() { var tls = require('tls'); // letsencrypt - var email = argv.email; - var agreeTos = argv.agreeTos || argv['agree-tos']; - var cert = require('localhost.daplie.com-certificates'); var opts = { - key: cert.key - , cert: cert.cert - //, ca: cert.ca - - , email: email - , agreeTos: agreeTos + agreeTos: argv.agreeTos || argv['agree-tos'] + , debug: argv.debug + , email: argv.email + , httpsOptions: { + key: cert.key + , cert: cert.cert + //, ca: cert.ca + } + , argv: argv }; var peerCa; + var p; - opts.SNICallback = function (servername, cb) { - cb(null, tls.createSecureContext(opts)); + opts.httpsOptions.SNICallback = function (servername, cb) { + cb(null, tls.createSecureContext(opts.httpsOptions)); return; }; @@ -161,8 +227,8 @@ function run() { argv.root = [argv.root]; } - opts.key = fs.readFileSync(argv.key); - opts.cert = fs.readFileSync(argv.cert); + 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) { @@ -181,9 +247,9 @@ function run() { // TODO * `--verify /path/to/root.pem` require peers to present certificates from said authority if (argv.verify) { - opts.ca = peerCa; - opts.requestCert = true; - opts.rejectUnauthorized = true; + opts.httpsOptions.ca = peerCa; + opts.httpsOptions.requestCert = true; + opts.httpsOptions.rejectUnauthorized = true; } if (argv['serve-root']) { @@ -208,6 +274,29 @@ function run() { opts.expressApp = require(path.resolve(process.cwd(), argv['express-app'])); } + if (opts.email || opts.servername) { + 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 + , silent: true + }, { + debug: false + , email: opts.argv.email + }).then(function (refreshToken) { + opts.refreshToken = refreshToken; + }); + } + else { + p = PromiseA.resolve(); + } + + return p.then(function () { return createServer(port, pubdir, content, opts).then(function () { var msg; var p; @@ -252,7 +341,7 @@ function run() { return promise.then(function (matchingIps) { if (matchingIps) { if (!matchingIps.length) { - console.log("Neither the attached nor external interfaces match '" + argv.servername + "'"); + console.info("Neither the attached nor external interfaces match '" + argv.servername + "'"); } opts.matchingIps = matchingIps || []; } @@ -292,11 +381,11 @@ function run() { } console.info('\t' + httpsUrl); - httpsUrl = 'https://[' + iface.ipv6[0].address + ']'; - if (443 !== opts.port) { - httpsUrl += ':' + opts.port; - } if (iface.ipv6.length) { + httpsUrl = 'https://[' + iface.ipv6[0].address + ']'; + if (443 !== opts.port) { + httpsUrl += ':' + opts.port; + } console.info('\t' + httpsUrl); } } @@ -305,6 +394,7 @@ function run() { console.info(''); }); }); + }); } if (require.main === module) {