From 13033d018ebdcf9bdb6ce746dd6f07ff05225a0e Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 22 Dec 2018 07:31:13 -0700 Subject: [PATCH] v2.6.3: simplify existing defaults, add default servername support --- README.md | 79 +++++++++---------- index.js | 214 ++++++++++++++++++++++++++++++++++++--------------- package.json | 4 +- 3 files changed, 190 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index c41f519..2d29f78 100644 --- a/README.md +++ b/README.md @@ -114,43 +114,39 @@ All you have to do is start the webserver and then visit it at its domain name. 'use strict'; require('greenlock-express').create({ + email: 'john.doe@example.com' // The email address of the ACME user / hosting provider +, agreeTos: true // You must accept the ToS as the host which handles the certs +, configDir: '~/.config/acme/' // Writable directory where certs will be saved +, communityMember: true // Join the community to get notified of important updates +, telemetry: true // Contribute telemetry data to the project - // Let's Encrypt v2 is ACME draft 11 - version: 'draft-11' - - // Note: If at first you don't succeed, switch to staging to debug - // https://acme-staging-v02.api.letsencrypt.org/directory -, server: 'https://acme-v02.api.letsencrypt.org/directory' - - // Where the certs will be saved, MUST have write access -, configDir: '~/.config/acme/' - - // You MUST change this to a valid email address -, email: 'john.doe@example.com' - - // You MUST change these to valid domains - // NOTE: all domains will validated and listed on the certificate -, approvedDomains: [ 'example.com', 'www.example.com' ] - - // You MUST NOT build clients that accept the ToS without asking the user -, agreeTos: true - -, app: require('express')().use('/', function (req, res) { - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.end('Hello, World!\n\nšŸ’š šŸ”’.js'); - }) - - // Join the community to get notified of important updates -, communityMember: true - - // Contribute telemetry data to the project -, telemetry: true + // Using your express app: + // simply export it as-is, then include it here +, app: require('./app.js') //, debug: true - }).listen(80, 443); ``` +`app.js`: +```js +'use strict'; + +var express = require('express'); +var app = express(); + +app.use('/', function (req, res) { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end('Hello, World!\n\nšŸ’š šŸ”’.js'); +}) + +// Don't do this: +// app.listen(3000) + +// Do this instead: +module.exports = app; +``` + ### `communityMember` If you're the kind of person that likes the kinds of stuff that I do, @@ -181,7 +177,6 @@ Double check the following: * You MUST set `email` to a **valid address** * MX records must validate (`dig MX example.com` for `'john@example.com'`) * **valid DNS records** - * You MUST set `approveDomains` to real domains * Must have public DNS records (test with `dig +trace A example.com; dig +trace www.example.com` for `[ 'example.com', 'www.example.com' ]`) * **write access** * You MUST set `configDir` to a writeable location (test with `touch ~/acme/etc/tmp.tmp`) @@ -320,6 +315,10 @@ var glx = require('greenlock-express').create({ // Contribute telemetry data to the project , telemetry: true + // the default servername to use when the client doesn't specify + // (because some IoT devices don't support servername indication) +, servername: 'example.com' + , approveDomains: approveDomains }); @@ -345,6 +344,10 @@ var http01 = require('le-challenge-fs').create({ webrootPath: '/tmp/acme-challen function approveDomains(opts, certs, cb) { // This is where you check your database and associated // email addresses with domains and agreements and such + // if (!isAllowed(opts.domains)) { return cb(new Error("not allowed")); } + + // The domains being approved for the first time are listed in opts.domains + // Certs being renewed are listed in certs.altnames (if that's useful) // Opt-in to submit stats and get important updates opts.communityMember = true; @@ -352,11 +355,6 @@ function approveDomains(opts, certs, cb) { // If you wish to replace the default challenge plugin, you may do so here opts.challenges = { 'http-01': http01 }; - // The domains being approved for the first time are listed in opts.domains - // Certs being renewed are listed in certs.altnames - if (certs) { - opts.domains = [certs.subject].concat(certs.altnames); - } opts.email = 'john.doe@example.com'; opts.agreeTos = true; @@ -388,11 +386,10 @@ require('https').createServer(glx.httpsOptions, app).listen(443, function () { }); ``` -**Security Warning**: +**Security**: -If you don't do proper checks in `approveDomains(opts, certs, cb)` -an attacker will spoof SNI packets with bad hostnames and that will -cause you to be rate-limited and or blocked from the ACME server. +Greenlock will do a self-check on all domain registrations +to prevent you from hitting rate limits. # API diff --git a/index.js b/index.js index cbab69f..abab3fd 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ module.exports.create = function (opts) { console.error(e.code + ": '" + e.address + ":" + e.port + "'"); } - function _listen(plainPort, plain) { + function _createPlain(plainPort) { if (!plainPort) { plainPort = 80; } var parts = String(plainPort).split(':'); @@ -41,53 +41,54 @@ module.exports.create = function (opts) { var server; var validHttpPort = (parseInt(p, 10) >= 0); - function tryPlain() { - server = require('http').createServer( - greenlock.middleware.sanitizeHost(greenlock.middleware(require('redirect-https')())) - ); - httpType = 'http'; - } - - function trySecure() { - var https; - try { - https = require('spdy'); - greenlock.tlsOptions.spdy = { protocols: [ 'h2', 'http/1.1' ], plain: false }; - httpType = 'http2 (spdy/h2)'; - } catch(e) { - https = require('https'); - httpType = 'https'; - } - server = https.createServer( - greenlock.tlsOptions - , greenlock.middleware.sanitizeHost(function (req, res) { - try { - greenlock.app(req, res); - } catch(e) { - console.error("[error] [greenlock.app] Your HTTP handler had an uncaught error:"); - console.error(e); - try { - res.statusCode = 500; - res.end("Internal Server Error: [Greenlock] HTTP exception logged for user-provided handler."); - } catch(e) { - // ignore - // (headers may have already been sent, etc) - } - } - }) - ); - server.type = httpType; - } - if (addr) { args[1] = addr; } if (!validHttpPort && !/(\/)|(\\\\)/.test(p)) { console.warn("'" + p + "' doesn't seem to be a valid port number, socket path, or pipe"); } - if (plain) { tryPlain(); } else { trySecure(); } - var promise = new PromiseA(function (resolve) { + server = require('http').createServer( + greenlock.middleware.sanitizeHost(greenlock.middleware(require('redirect-https')())) + ); + httpType = 'http'; + + return { server: server, listen: function () { return new Promise(function (resolve, reject) { args[0] = p; - args.push(function () { resolve(server); }); + args.push(function () { + if (!greenlock.servername) { + if (Array.isArray(greenlock.approvedDomains) && greenlock.approvedDomains.length) { + greenlock.servername = greenlock.approvedDomains[0]; + } + if (Array.isArray(greenlock.approveDomains) && greenlock.approvedDomains.length) { + greenlock.servername = greenlock.approvedDomains[0]; + } + } + if (!greenlock.servername) { + resolve(null); + return; + } + return greenlock.check({ domains: [ greenlock.servername ] }).then(function (certs) { + if (certs) { + console.info("Using '%s' as default certificate", greenlock.servername); + return { + key: Buffer.from(certs.privkey, 'ascii') + , cert: Buffer.from(certs.cert + '\r\n' + certs.chain, 'ascii') + }; + } + console.info("Fetching certificate for '%s' to use as default for HTTPS server...", greenlock.servername); + return new PromiseA(function (resolve, reject) { + // using SNICallback because all options will be set + greenlock.tlsOptions.SNICallback(greenlock.servername, function (err/*, secureContext*/) { + if (err) { reject(err); return; } + return greenlock.check({ domains: [ greenlock.servername ] }).then(function (certs) { + resolve({ + key: Buffer.from(certs.privkey, 'ascii') + , cert: Buffer.from(certs.cert + '\r\n' + certs.chain, 'ascii') + }); + }).catch(reject); + }); + }); + }).then(resolve).catch(reject); + }); server.listen.apply(server, args).on('error', function (e) { if (server.listenerCount('error') < 2) { console.warn("Did not successfully create http server and bind to port '" + p + "':"); @@ -95,10 +96,90 @@ module.exports.create = function (opts) { process.exit(41); } }); - }); + }); } }; + } - promise.server = server; - return promise; + function _create(port) { + if (!port) { port = 443; } + + var parts = String(port).split(':'); + var p = parts.pop(); + var addr = parts.join(':').replace(/^\[/, '').replace(/\]$/, ''); + var args = []; + var httpType; + var server; + var validHttpPort = (parseInt(p, 10) >= 0); + + if (addr) { args[1] = addr; } + if (!validHttpPort && !/(\/)|(\\\\)/.test(p)) { + console.warn("'" + p + "' doesn't seem to be a valid port number, socket path, or pipe"); + } + + var https; + try { + https = require('spdy'); + greenlock.tlsOptions.spdy = { protocols: [ 'h2', 'http/1.1' ], plain: false }; + httpType = 'http2 (spdy/h2)'; + } catch(e) { + https = require('https'); + httpType = 'https'; + } + var sniCallback = greenlock.tlsOptions.SNICallback; + greenlock.tlsOptions.SNICallback = function (domain, cb) { + sniCallback(domain, function (err, context) { + cb(err, context); + if (!context || server._hasDefaultSecureContext) { + return; + } + return greenlock.check({ domains: [ domain ] }).then(function (certs) { + // ignore the case that check doesn't have all the right args here + // to get the same certs that it just got (eventually the right ones will come in) + if (!certs) { return; } + console.info("Using '%s' as default certificate", domain); + server.setSecureContext({ + key: Buffer.from(certs.privkey, 'ascii') + , cert: Buffer.from(certs.cert + '\r\n' + certs.chain, 'ascii') + }); + server._hasDefaultSecureContext = true; + }).catch(function (/*e*/) { + // this may be that the test.example.com was requested, but it's listed + // on the cert for demo.example.com which is in its own directory, not the other + //console.warn("Unusual error: couldn't get newly authorized certificate:"); + //console.warn(e.message); + }); + }); + }; + server = https.createServer( + greenlock.tlsOptions + , greenlock.middleware.sanitizeHost(function (req, res) { + try { + greenlock.app(req, res); + } catch(e) { + console.error("[error] [greenlock.app] Your HTTP handler had an uncaught error:"); + console.error(e); + try { + res.statusCode = 500; + res.end("Internal Server Error: [Greenlock] HTTP exception logged for user-provided handler."); + } catch(e) { + // ignore + // (headers may have already been sent, etc) + } + } + }) + ); + server.type = httpType; + + return { server: server, listen: function () { return new PromiseA(function (resolve) { + args[0] = p; + args.push(function () { resolve(/*server*/); }); + server.listen.apply(server, args).on('error', function (e) { + if (server.listenerCount('error') < 2) { + console.warn("Did not successfully create http server and bind to port '" + p + "':"); + explainError(e); + process.exit(41); + } + }); + }); } }; } // NOTE: 'greenlock' is just 'opts' renamed @@ -111,7 +192,6 @@ module.exports.create = function (opts) { } opts.listen = function (plainPort, port, fnPlain, fn) { - var promises = []; var server; var plainServer; @@ -122,28 +202,34 @@ module.exports.create = function (opts) { fnPlain = null; } - promises.push(_listen(plainPort, true)); - promises.push(_listen(port, false)); + var obj1 = _createPlain(plainPort, true); + var obj2 = _create(port, false); - server = promises[1].server; - plainServer = promises[0].server; + plainServer = obj1.server; + server = obj2.server; - PromiseA.all(promises).then(function () { - // Report plain http status - if ('function' === typeof fnPlain) { - fnPlain.apply(plainServer); - } else if (!fn && !plainServer.listenerCount('listening') && !server.listenerCount('listening')) { - console.info('[:' + (plainServer.address().port || plainServer.address()) - + "] Handling ACME challenges and redirecting to " + server.type); + server.then = obj1.listen().then(function (tlsOptions) { + if (tlsOptions) { + server.setSecureContext(tlsOptions); + server._hasDefaultSecureContext = true; } + return obj2.listen().then(function () { + // Report plain http status + if ('function' === typeof fnPlain) { + fnPlain.apply(plainServer); + } else if (!fn && !plainServer.listenerCount('listening') && !server.listenerCount('listening')) { + console.info('[:' + (plainServer.address().port || plainServer.address()) + + "] Handling ACME challenges and redirecting to " + server.type); + } - // Report h2/https status - if ('function' === typeof fn) { - fn.apply(server); - } else if (!server.listenerCount('listening')) { - console.info('[:' + (server.address().port || server.address()) + "] Serving " + server.type); - } - }); + // Report h2/https status + if ('function' === typeof fn) { + fn.apply(server); + } else if (!server.listenerCount('listening')) { + console.info('[:' + (server.address().port || server.address()) + "] Serving " + server.type); + } + }); + }).then; server.unencrypted = plainServer; return server; diff --git a/package.json b/package.json index 4b374a6..5569b12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenlock-express", - "version": "2.5.0", + "version": "2.6.3", "description": "Free SSL and managed or automatic HTTPS for node.js with Express, Koa, Connect, Hapi, and all other middleware systems.", "main": "index.js", "homepage": "https://git.coolaj86.com/coolaj86/greenlock-express.js", @@ -8,7 +8,7 @@ "example": "examples" }, "dependencies": { - "greenlock": "^2.5.0", + "greenlock": "^2.6.6", "le-challenge-fs": "^2.0.8", "le-sni-auto": "^2.1.4", "le-store-certbot": "^2.1.0",