initial commit
This commit is contained in:
parent
2a9ac61367
commit
9d69a0f4a9
104
README.md
104
README.md
|
@ -1,2 +1,102 @@
|
||||||
# le-sni-auto
|
le-sni-auto
|
||||||
An auto-sni strategy for registering and renewing letsencrypt certificates using SNICallback
|
===========
|
||||||
|
|
||||||
|
**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)`.
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue