initial commit

This commit is contained in:
AJ ONeal 2016-08-10 20:37:03 -04:00
parent 2a9ac61367
commit 9d69a0f4a9
3 changed files with 367 additions and 2 deletions

104
README.md
View File

@ -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)`.

123
index.js Normal file
View File

@ -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;
};

142
test.js Normal file
View File

@ -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");
});
});
});
});
});