From 7f826369a6ccfa345fc42abe12ffe11ac47d087e Mon Sep 17 00:00:00 2001 From: Ben Schmidt Date: Sat, 8 Oct 2016 15:23:32 +1100 Subject: [PATCH] support uncaching and non-automatic certificates This facilitates temporarily installing certificates to satisfy TLS SNI challenges. Promises are also shared to avoid simultaneously obtaining certificates when initially loading/registering one or after expiry. --- README.md | 20 ++++++++++- index.js | 32 +++++++++++++---- test.js | 103 +++++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 128 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8baed56..24c4820 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ API * `renewBy` (default 2 days, min 12 hours) * `sniCallback(domain, cb)` * `cacheCerts(certs)` +* `uncacheDomain(domain)` .renewWithin ----------- @@ -162,7 +163,8 @@ https.createServer(httpsOptions, app); Manually load a certificate into the cache. This is useful in a cluster environment where the master -may wish to inform multiple workers of a new or renewed certificate. +may wish to inform multiple workers of a new or renewed certificate, +or to satisfy tls-sni-01 challenges. ``` leSni.cacheCerts({ @@ -172,5 +174,21 @@ leSni.cacheCerts({ , altnames: [ 'example.com', 'www.example.com' ] , issuedAt: 1470975565000 , expiresAt: 1478751565000 +, auto: true }); ``` + +.uncacheCerts() +----------- + +Remove cached certificates from the cache. + +This is useful once a tls-sni-01 challenge has been satisfied. + +``` +leSni.uncacheCerts({ +, subject: 'example.com' +, altnames: [ 'example.com', 'www.example.com' ] +}); +``` + diff --git a/index.js b/index.js index 5f2b9c9..ef32d77 100644 --- a/index.js +++ b/index.js @@ -21,7 +21,7 @@ module.exports.create = function (autoSni) { if (autoSni.renewWithin < defaults._renewWithinMin) { throw new Error("options.renewWithin should be at least 3 days"); } - if (!autoSni.renewBy) { autoSni.renewBy = autoSni.notBefore || defaults.renewBy; } + if (!autoSni.renewBy) { autoSni.renewBy = autoSni.notAfter || defaults.renewBy; } if (autoSni.renewBy < defaults._renewByMin) { throw new Error("options.renewBy should be at least 12 hours"); } @@ -72,6 +72,7 @@ module.exports.create = function (autoSni) { }) || { '_fake_tls_context_': true } , subject: certs.subject + , auto: 'undefined' === typeof certs.auto ? true : certs.auto // stagger renewal time by a little bit of randomness , renewAt: (certs.expiresAt - (autoSni.renewWithin - (autoSni._renewWindow * Math.random()))) // err just barely on the side of safety @@ -90,13 +91,23 @@ module.exports.create = function (autoSni) { + , uncacheCerts: function (certs) { + certs.altnames.forEach(function (domain) { + delete autoSni._ipc[domain]; + }); + delete autoSni._ipc[certs.subject]; + } + + + + // 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) { + if (certMeta && !certMeta.then && certMeta.subject !== domain) { //log(autoSni.debug, "LINK CERT", domain); certMeta = autoSni._ipc[certMeta.subject]; } @@ -104,16 +115,23 @@ module.exports.create = function (autoSni) { if (!certMeta) { //log(autoSni.debug, "NO CERT", domain); // we don't have a cert and must get one - promise = autoSni.getCertificatesAsync(domain, null); + promise = autoSni.getCertificatesAsync(domain, null).then(autoSni.cacheCerts); + autoSni._ipc[domain] = promise; + } + else if (certMeta.then) { + //log(autoSni.debug, "PROMISED CERT", domain); + // we are already getting a cert + promise = certMeta } else if (now >= certMeta.expiresNear) { //log(autoSni.debug, "EXPIRED CERT"); // we have a cert, but it's no good for the average user - promise = autoSni.getCertificatesAsync(domain, certMeta.certs); + promise = autoSni.getCertificatesAsync(domain, certMeta.certs).then(autoSni.cacheCerts); + autoSni._ipc[certMeta.subject] = promise; } else { // it's time to renew the cert - if (now >= certMeta.renewAt) { + if (certMeta.auto && now >= certMeta.renewAt) { //log(autoSni.debug, "RENEWABLE CERT"); // 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()); @@ -127,12 +145,14 @@ module.exports.create = function (autoSni) { } // promise the non-existent or expired cert - promise.then(autoSni.cacheCerts).then(function (certMeta) { + promise.then(function (certMeta) { cb(null, certMeta.tlsContext); }, function (err) { console.error('ERROR in le-sni-auto:'); console.error(err.stack || err); cb(err); + // don't reuse this promise + delete autoSni._ipc[certMeta && certMeta.subject ? certMeta.subject : domain]; }); } diff --git a/test.js b/test.js index ca47b61..3b0cffd 100644 --- a/test.js +++ b/test.js @@ -17,13 +17,19 @@ var CERT_2 = { , subject: 'example.com' , altnames: ['example.com', 'www.example.com'] }; +var CERT_3 = { + expiresAt: EXPIRES_AT +, subject: 'example.com' +, altnames: ['example.com', 'www.example.com'] +, auto: false +}; var count = 0; -var expectedCount = 3; +var expectedCount = 4; var tests = [ function (domain, certs, cb) { count += 1; - console.log('#1 is 1 of 3'); + console.log('#1 is 1 of 4'); if (!domain) { throw new Error("should have a domain"); } @@ -42,7 +48,7 @@ var tests = [ } , function (domain, certs, cb) { count += 1; - console.log('#3 is 2 of 3'); + console.log('#3 is 2 of 4'); // 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)"); @@ -52,7 +58,7 @@ var tests = [ } , function (domain, certs, cb) { count += 1; - console.log('#4 is 3 of 3'); + console.log('#4 is 3 of 4'); if (!certs) { throw new Error("should have certs to renew (expiresNear)"); } @@ -63,6 +69,19 @@ var tests = [ console.log('#5 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('#6 is 4 of 4'); + if (certs) { + throw new Error("should not have certs that have been uncached"); + } + + cb(null, CERT_3); + } +, function (/*domain, certs, cb*/) { + console.log('#7 should NOT be called'); + throw new Error("Should not call register renew a non-auto certificate"); + } ].map(function (fn) { return require('bluebird').promisify(fn); }); @@ -75,10 +94,16 @@ var leSni = require('./').create({ , _dbg_now: START_DAY }); +var shared = 0; +var expectedShared = 3; +leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + shared += 1; +}); leSni.sniCallback('example.com', function (err, tlsContext) { if (err) { throw err; } if (!tlsContext._fake_tls_context_) { - throw new Error("Did not return tlsContext 0"); + throw new Error("Did not return tlsContext #1"); } leSni.getCertificatesAsync = tests.shift(); @@ -88,7 +113,7 @@ leSni.sniCallback('example.com', function (err, tlsContext) { leSni.sniCallback('example.com', function (err, tlsContext) { if (err) { throw err; } if (!tlsContext._fake_tls_context_) { - throw new Error("Did not return tlsContext 1"); + throw new Error("Did not return tlsContext #2"); } leSni.getCertificatesAsync = tests.shift(); @@ -97,10 +122,14 @@ leSni.sniCallback('example.com', function (err, tlsContext) { + leSni.sniCallback('www.example.com', function (err, tlsContext) { + if (err) { throw err; } + shared += 1; + }); leSni.sniCallback('example.com', function (err, tlsContext) { if (err) { throw err; } if (!tlsContext._fake_tls_context_) { - throw new Error("Did not return tlsContext 2"); + throw new Error("Did not return tlsContext #3"); } leSni.getCertificatesAsync = tests.shift(); @@ -109,33 +138,67 @@ leSni.sniCallback('example.com', function (err, tlsContext) { - leSni.sniCallback('example.com', function (err, tlsContext) { + leSni.sniCallback('www.example.com', function (err, tlsContext) { + if (err) { throw err; } + shared += 1; + }); + leSni.sniCallback('www.example.com', function (err, tlsContext) { if (err) { throw err; } if (!tlsContext._fake_tls_context_) { - throw new Error("Did not return tlsContext 2"); + throw new Error("Did not return tlsContext #4"); } leSni.getCertificatesAsync = tests.shift(); - leSni.sniCallback('example.com', function (err, tlsContext) { + leSni.sniCallback('www.example.com', function (err, tlsContext) { if (err) { throw err; } if (!tlsContext._fake_tls_context_) { - throw new Error("Did not return tlsContext 2"); + throw new Error("Did not return tlsContext #5"); } + leSni.uncacheCerts({ + subject: 'example.com' + , altnames: ['example.com', 'www.example.com'] + }); + leSni.getCertificatesAsync = tests.shift(); - if (expectedCount === count && !tests.length) { - console.log('PASS'); - return; - } - throw new Error("only " + count + " of the register getCertificate were called"); + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (err) { throw err; } + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext #6"); + } + leSni.getCertificatesAsync = tests.shift(); + + leSni._dbg_now = RENEWABLE_DAY; + + + + + leSni.sniCallback('example.com', function (err, tlsContext) { + if (!tlsContext._fake_tls_context_) { + throw new Error("Did not return tlsContext #7"); + } + + if (expectedCount !== count) { + throw new Error("getCertificate only called " + count + " times"); + } + + if (expectedShared !== shared) { + throw new Error("wrongly used only " + shared + " shared promises"); + } + + if (tests.length) { + throw new Error("some test functions not run"); + } + + console.log('PASS'); + }); + }); }); - - - - }); }); });