From 75d259dbb1dd1997cfa8297a7670c27bb9c5bb2b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 17 Dec 2015 03:03:07 +0000 Subject: [PATCH] sni callback stuff --- README.md | 23 ++++++-- lib/sni-callback.js | 128 ++++++++++++++++++++++++++++++++++++++++++++ lib/standalone.js | 7 ++- 3 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 lib/sni-callback.js diff --git a/README.md b/README.md index a21dddf..77e177f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ npm install --save letsencrypt-express 'use strict'; // Note: using staging server url, remove .testing() for production -var le = require('letsencrypt-express').testing(); +var lex = require('letsencrypt-express').testing(); var express = require('express'); var app = express(); @@ -32,7 +32,7 @@ app.use('/', function (req, res) { res.send({ success: true }); }); -le.create('/etc/letsencrypt', app).listen([80], [443, 5001], function () { +lex.create('/etc/letsencrypt', app).listen([80], [443, 5001], function () { console.log("ENCRYPT __ALL__ THE DOMAINS!"); }); ``` @@ -42,7 +42,7 @@ le.create('/etc/letsencrypt', app).listen([80], [443, 5001], function () { ```javascript 'use strict'; -var le = require('letsencrypt-express'); +var lex = require('letsencrypt-express'); var express = require('express'); var app = express(); @@ -50,7 +50,7 @@ app.use('/', function (req, res) { res.send({ success: true }); }); -var results = le.create({ +var results = lex.create({ configDir: '/etc/letsencrypt' , onRequest: app , server: require('letsencrypt').productionServerUrl @@ -84,6 +84,21 @@ results.tlsServers.forEach(function (server) { }); ``` +## API + +``` +LEX.create(options) // checks options and sets up defaults. returns object with `listen` + // (it was really just done this way to appeal to what people are used to seeing) + + lex.listen(plain, tls, fn) // actually creates the servers and causes them to listen + +LEX.createSniCallback(le) // receives an instance of letsencrypt, returns an SNICallback handler for https.createServer() + + +LEX.getChallenge(opts, hostname, key cb) // uses `opts.webrootPath` to read from the filesystem + +``` + ## Options If any of these values are `undefined` or `null` the will assume use reasonable defaults. diff --git a/lib/sni-callback.js b/lib/sni-callback.js new file mode 100644 index 0000000..ab599cf --- /dev/null +++ b/lib/sni-callback.js @@ -0,0 +1,128 @@ +'use strict'; + +var crypto = require('crypto'); +var tls = require('tls'); + +module.exports.create = function (memos) { + var ipc = {}; // in-process cache + + if (!memos) { throw new Error("requires opts to be an object"); } + if (!memos.letsencrypt) { throw new Error("requires opts.letsencrypt to be a letsencrypt instance"); } + + if (!memos.lifetime) { memos.lifetime = 90 * 24 * 60 * 60 * 1000; } + if (!memos.failedWait) { memos.failedWait = 5 * 60 * 1000; } + if (!memos.renewWithin) { memos.renewWithin = 3 * 24 * 60 * 60 * 1000; } + if (!memos.memorizeFor) { memos.memorizeFor = 1 * 24 * 60 * 60 * 1000; } + + if (!memos.handleRegistration) { memos.handleRegistration = function (args, cb) { cb(null, null); }; } + if (!memos.handleRenewFailure) { memos.handleRenewFailure = function () {}; } + + function assignBestByDates(now, certInfo) { + certInfo = certInfo || { loadedAt: now, expiresAt: 0, issuedAt: 0, lifetime: 0 }; + + var rnds = crypto.randomBytes(3)[0]; + var rnd1 = ((rnds[0] + 1) / 257); + var rnd2 = ((rnds[1] + 1) / 257); + var rnd3 = ((rnds[2] + 1) / 257); + + // Stagger randomly by plus 0% to 25% to prevent all caches expiring at once + var memorizeFor = Math.floor(memos.memorizeFor + ((memos.memorizeFor / 4) * rnd1)); + // Stagger randomly to renew between n and 2n days before renewal is due + // this *greatly* reduces the risk of multiple cluster processes renewing the same domain at once + var bestIfUsedBy = certInfo.expiresAt - (memos.renewWithin + Math.floor(memos.renewWithin * rnd2)); + // Stagger randomly by plus 0 to 5 min to reduce risk of multiple cluster processes + // renewing at once on boot when the certs have expired + var renewTimeout = Math.floor((5 * 60 * 1000) * rnd3); + + certInfo.loadedAt = now; + certInfo.memorizeFor = memorizeFor; + certInfo.bestIfUsedBy = bestIfUsedBy; + certInfo.renewTimeout = renewTimeout; + } + + function renewInBackground(now, hostname, certInfo) { + if ((now - certInfo.loadedAt) < memos.failedWait) { + // wait a few minutes + return; + } + + if (now > certInfo.bestIfUsedBy && !certInfo.timeout) { + // EXPIRING + if (now > certInfo.expiresAt) { + // EXPIRED + certInfo.renewTimeout = Math.floor(certInfo.renewTimeout / 2); + } + + certInfo.timeout = setTimeout(function () { + var opts = { domains: [ hostname ], duplicate: false }; + le.renew(opts, function (err, certInfo) { + if (err || !certInfo) { + memos.handleRenewFailure(err, certInfo, opts); + } + ipc[hostname] = assignBestByDates(now, certInfo); + }); + }, certInfo.renewTimeout); + } + } + + function fetch(hostname, cb) { + le.fetch({ domains: [hostname] }, function (err, certInfo) { + var now = Date.now(); + + ipc[hostname] = assignBestByDates(now, certInfo); + if (!certInfo) { + // handles registration + memos.handleRegistration(hostname, cb); + return; + } + + // handles renewals + renewInBackground(now, hostname, certInfo); + + if (err) { + cb(err); + return; + } + + try { + certInfo.tlsContext = tls.createSecureContext({ + key: certInfo.key // privkey.pem + , cert: certInfo.cert // fullchain.pem (cert.pem + '\n' + chain.pem) + }); + } catch(e) { + console.warn("[Sanity Check Fail]: a weird object was passed back through le.fetch to lex.fetch"); + cb(e); + return; + } + + cb(null, certInfo.tlsContext); + }); + } + + return function sniCallback(hostname, cb) { + var now = Date.now(); + var certInfo = ipc[hostname]; + + // TODO once ECDSA is available, wait for cert renewal if its due + if (!certInfo) { + fetch(hostname, cb); + return; + } + + if (certInfo.context) { + cb(null, certInfo.context); + + if ((now - certInfo.loadedAt) < (certInfo.memorizeFor)) { + // these aren't stale, so don't fall through + return; + } + } + else if ((now - certInfo.loadedAt) < memos.failedWait) { + // this was just fetched and failed, wait a few minutes + cb(null, null); + return; + } + + fetch({ domains: [hostname] }, cb); + }; +}; diff --git a/lib/standalone.js b/lib/standalone.js index d036e83..90c144b 100644 --- a/lib/standalone.js +++ b/lib/standalone.js @@ -2,6 +2,7 @@ var path = require('path'); var challengeStore = require('./lib/challange-handlers'); +var createSniCallback = require('./lib/sni-callback').create; var LE = require('letsencrypt'); function LEX(obj, app) { @@ -105,6 +106,7 @@ function LEX(obj, app) { httpsOptions.SNICallback = obj.sniCallback; } else if (sniCallback) { + obj._sniCallback = createSniCallback(obj); httpsOptions.SNICallback = function (domain, cb) { sniCallback(domain, function (err, context) { if (context) { @@ -112,12 +114,12 @@ function LEX(obj, app) { return; } - obj.letsencrypt.sniCallback(domain, cb); + obj._sniCallback(domain, cb); }); }; } else { - httpsOptions.SNICallback = obj.letsencrypt.sniCallback; + httpsOptions.SNICallback = createSniCallback(obj); } function listen(plainPorts, tlsPorts, onListening) { @@ -190,6 +192,7 @@ function LEX(obj, app) { } module.exports = LEX; + LEX.create = LEX; LEX.setChallenge = challengeStore.set; LEX.getChallenge = challengeStore.get;