initial commit
This commit is contained in:
parent
2a9ac61367
commit
9d69a0f4a9
104
README.md
104
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)`.
|
||||
|
|
|
@ -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