diff --git a/README.md b/README.md index 3dc5814..c3d0584 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ -# le-sni-auto -An auto-sni strategy for registering and renewing letsencrypt certificates using SNICallback +le-sni-auto +=========== + +**DRAFT** this is not yet published to npm + +An auto-sni strategy for registering and renewing letsencrypt certificates using SNICallback. + +This does a couple of rather simple things: + + * caches certificates in memory + * calls `getCertificatesAsync(domain, null)` when a certificate is not in memory + * calls `getCertificatesASync(domain, certs)` when a certificate is up for renewal or expired + +Install +======= + +```bash +npm install --save le-sni-auto@2.x +``` + +Usage +===== + +With node-letsencrypt +--------------------- + +```javascript +'use strict'; + + + +var leSni = require('le-sni-auto').create({ + + notBefore: 10 * 24 * 60 * 60 1000 // do not renew more than 10 days before expiration +, notAfter: 5 * 24 * 60 * 60 1000 // do not wait more than 5 days before expiration + +, httpsOptions: { + rejectUnauthorized: true // These options will be used with tls.createSecureContext() + , requestCert: false // in addition to key (privkey.pem) and cert (cert.pem + chain.pem), + , ca: null // which are provided by letsencrypt + , crl: null + } + +}); + + + +var le = require('letsencrypt').create({ + server: 'staging' + +, sni: leSni + +, approveDomains: function (domain, cb) { + // here you would lookup details such as email address in your db + cb(null, { email: 'john.doe@gmail.com.', domains: [domain, 'www.' + domain], agreeTos: true }} + } +}); + + + +var app = require('express')(); +var httpsOptions = { SNICallback: le.sni.callback }; + +httpsOptions = require('localhost.daplie.com-certificates').merge(httpsOptions); + + +http.createServer(le.handleAcmeOrRedirectToHttps()); +https.createServer(dummyCerts, le.handleAcmeOrUse(app)).listen(443); +``` + +You can also provide a thunk-style `getCertificates(domain, certs, cb)`. + +Standalone +---------- + +```javascript +'use strict'; + + +var le = require('letsencrypt').create({ + notBefore: 10 * 24 * 60 * 60 1000 // do not renew prior to 10 days before expiration +, notAfter: 5 * 24 * 60 * 60 1000 // do not wait more than 5 days before expiration + + // key (privkey.pem) and cert (cert.pem + chain.pem) will be provided by letsencrypt +, httpsOptions: { rejectUnauthorized: true, requestCert: false, ca: null, crl: null } + +, getCertificatesAsync: function (domain, certs) { + // return a promise with an object with the following keys: + // { privkey, cert, chain, expiresAt, issuedAt, subject, altnames } + } +}); + + + + +var dummyCerts = require('localhost.daplie.com-certificates'); +dummyCerts.SNICallback = le.sni.sniCallback; + +https.createServer(dummyCerts, ); +``` + +You can also provide a thunk-style `getCertificates(domain, certs, cb)`. diff --git a/index.js b/index.js new file mode 100644 index 0000000..4c98f5c --- /dev/null +++ b/index.js @@ -0,0 +1,123 @@ +'use strict'; + +// autoSni = { notBefore, notAfter, getCertificates, httpsOptions, _dbg_now } +module.exports.create = function (autoSni) { + + var DAY = 24 * 60 * 60 * 1000; + var MIN = 60 * 1000; + if (!autoSni.getCertificatesAsync) { autoSni.getCertificatesAsync = require('bluebird').promisify(autoSni.getCertificates); } + if (!autoSni.notBefore) { throw new Error("must supply options.notBefore (and options.notAfter)"); } + if (!autoSni.notAfter) { autoSni.notAfter = autoSni.notBefore - (3 * DAY); } + if (!autoSni.httpsOptions) { autoSni.httpOptions = {}; } + + + + + //autoSni.renewWithin = autoSni.notBefore; // i.e. 15 days + autoSni.renewWindow = autoSni.notBefore - autoSni.notAfter; // i.e. 1 day + //autoSni.renewRatio = autoSni.notBefore = autoSni.renewWindow; // i.e. 1/15 (6.67%) + + + + + var tls = require('tls'); + + + + + var _autoSni = { + + + + + // in-process cache + _ipc: {} + // just to account for clock skew + , _fiveMin: 5 * MIN + + + + + // cache and format incoming certs + , _cacheCerts: function (certs) { + var meta = { + certs: certs + , tlsContext: !autoSni._dbg_now && tls.createSecureContext({ + key: certs.privkey + , cert: certs.cert + certs.chain + , rejectUnauthorized: autoSni.httpsOptions.rejectUnauthorized + + , requestCert: autoSni.httpsOptions.requestCert // request peer verification + , ca: autoSni.httpsOptions.ca // this chain is for incoming peer connctions + , crl: autoSni.httpsOptions.crl // this crl is for incoming peer connections + }) || { '_fake_tls_context_': true } + + , subject: certs.subject + // stagger renewal time by a little bit of randomness + , renewAt: (certs.expiresAt - (autoSni.notBefore - (autoSni.renewWindow * Math.random()))) + // err just barely on the side of safety + , expiresNear: certs.expiresAt - autoSni._fiveMin + }; + var link = { subject: certs.subject }; + + certs.altnames.forEach(function (domain) { + autoSni._ipc[domain] = link; + }); + autoSni._ipc[certs.subject] = meta; + + return meta; + } + + + + + // automate certificate registration on request + , sniCallback: function (domain, cb) { + var certMeta = autoSni._ipc[domain]; + var promise; + var now = (autoSni._dbg_now || Date.now()); + + if (certMeta && certMeta.subject !== domain) { + certMeta = autoSni._ipc[domain]; + } + + if (!certMeta) { + // we don't have a cert and must get one + promise = autoSni.getCertificatesAsync(domain, null); + } + else if (now >= certMeta.expiresNear) { + // we have a cert, but it's no good for the average user + promise = autoSni.getCertificatesAsync(domain, certMeta.certs); + } else { + + // it's time to renew the cert + if (now >= certMeta.renewAt) { + // give the cert some time (2-5 min) to be validated and replaced before trying again + certMeta.renewAt = (autoSni._dbg_now || Date.now()) + (2 * MIN) + (3 * MIN * Math.random()); + // let the update happen in the background + autoSni.getCertificatesAsync(domain, certMeta.certs).then(autoSni._cacheCerts); + } + + // return the valid cert right away + cb(null, certMeta.tlsContext); + return; + } + + // promise the non-existent or expired cert + promise.then(autoSni._cacheCerts).then(function (certMeta) { + cb(null, certMeta.tlsContext); + }, cb); + } + + + + + }; + + Object.keys(_autoSni).forEach(function (key) { + autoSni[key] = _autoSni[key]; + }); + _autoSni = null; + + return autoSni; +}; diff --git a/test.js b/test.js new file mode 100644 index 0000000..ca47b61 --- /dev/null +++ b/test.js @@ -0,0 +1,142 @@ +'use strict'; + +var DAY = 24 * 60 * 60 * 1000; +var MIN = 60 * 1000; +var START_DAY = new Date(2015, 0, 1, 17, 30, 0, 0).valueOf(); +var NOT_BEFORE = 10 * DAY; +var NOT_AFTER = 5 * DAY; +var EXPIRES_AT = START_DAY + NOT_BEFORE + (15 * MIN); +var RENEWABLE_DAY = EXPIRES_AT - (60 * MIN); +var CERT_1 = { + expiresAt: EXPIRES_AT +, subject: 'example.com' +, altnames: ['example.com', 'www.example.com'] +}; +var CERT_2 = { + expiresAt: EXPIRES_AT + NOT_BEFORE + (60 * MIN) +, subject: 'example.com' +, altnames: ['example.com', 'www.example.com'] +}; + +var count = 0; +var expectedCount = 3; +var tests = [ + function (domain, certs, cb) { + count += 1; + console.log('#1 is 1 of 3'); + if (!domain) { + throw new Error("should have a domain"); + } + + if (certs) { + console.log('certs'); + console.log(certs); + throw new Error("shouldn't have certs that don't even exist yet"); + } + + cb(null, CERT_1); + } +, function (/*domain, certs, cb*/) { + console.log('#2 should NOT be called'); + throw new Error("Should not call register renew a certificate with more than 10 days left"); + } +, function (domain, certs, cb) { + count += 1; + console.log('#3 is 2 of 3'); + // NOTE: there's a very very small chance this will fail occasionally (if Math.random() < 0.01) + if (!certs) { + throw new Error("should have certs to renew (renewAt)"); + } + + cb(null, CERT_1); + } +, function (domain, certs, cb) { + count += 1; + console.log('#4 is 3 of 3'); + if (!certs) { + throw new Error("should have certs to renew (expiresNear)"); + } + + cb(null, CERT_2); + } +, function (/*domain, certs, cb*/) { + console.log('#5 should NOT be called'); + throw new Error("Should not call register renew a certificate with more than 10 days left"); + } +].map(function (fn) { + return require('bluebird').promisify(fn); +}); + +// opts = { notBefore, notAfter, letsencrypt.renew, letsencrypt.register, httpsOptions } +var leSni = require('./').create({ + notBefore: NOT_BEFORE +, notAfter: NOT_AFTER +, getCertificatesAsync: tests.shift() +, _dbg_now: START_DAY +}); + +leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext 0"); + } + leSni.getCertificatesAsync = tests.shift(); + + + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext 1"); + } + leSni.getCertificatesAsync = tests.shift(); + + leSni._dbg_now = RENEWABLE_DAY; + + + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext 2"); + } + leSni.getCertificatesAsync = tests.shift(); + + leSni._dbg_now = EXPIRES_AT; + + + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext 2"); + } + leSni.getCertificatesAsync = tests.shift(); + + + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext 2"); + } + + if (expectedCount === count && !tests.length) { + console.log('PASS'); + return; + } + + throw new Error("only " + count + " of the register getCertificate were called"); + }); + + + + + }); + }); + }); +});