standalone complete! (I think?)

This commit is contained in:
AJ ONeal 2015-12-13 05:03:48 +00:00
parent 449b15b00c
commit b1fcb5b271
4 changed files with 190 additions and 94 deletions

View File

@ -3,7 +3,7 @@ letsencrypt
Let's Encrypt for node.js
This allows you to get Free SSL Certificates for Automatic HTTPS.
This enables you to get Free SSL Certificates for Automatic HTTPS.
#### NOT YET PUBLISHED
@ -67,6 +67,7 @@ API
* `le.register({ domains, email, agreeTos, ... }, cb)`
* `le.fetch({domains, email, agreeTos, ... }, cb)`
* `le.validate(domains, cb)`
* `le.registrationFailureCallback(err, args, certInfo, cb)`
### `LetsEncrypt.create(backend, bkDefaults, handlers)`
@ -214,6 +215,10 @@ Used internally, but exposed for convenience.
Checks in-memory cache of certificates for `args.domains` and calls then calls `backend.fetch(args, cb)`
**after** merging `args` if necessary.
### `le.registrationFailureCallback(err, args, certInfo, cb)`
Not yet implemented
Backends
--------

40
backends-python.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
var PromiseA = require('bluebird');
var fs = PromiseA.promisifyAll(require('fs'));
module.exports.create = function (leBinPath, defaults) {
defaults.webroot = true;
defaults.renewByDefault = true;
var LEP = require('letsencrypt-python');
var lep = PromiseA.promisifyAll(LEP.create(leBinPath, { debug: true }));
var wrapped = {
registerAsync: function (args) {
return lep.registerAsync('certonly', args);
}
, fetchAsync: function (args) {
var hostname = args.domains[0];
var crtpath = defaults.configDir + defaults.fullchainTpl.replace(/:hostname/, hostname);
var privpath = defaults.configDir + defaults.privkeyTpl.replace(/:hostname/, hostname);
return PromiseA.all([
fs.readFileAsync(privpath, 'ascii')
, fs.readFileAsync(crtpath, 'ascii')
// stat the file, not the link
, fs.statAsync(crtpath)
]).then(function (arr) {
return {
key: arr[0] // privkey.pem
, cert: arr[1] // fullchain.pem
// TODO parse centificate
, issuedAt: arr[2].mtime.valueOf()
};
}, function () {
return null;
});
}
};
return wrapped;
}

View File

@ -1,9 +1,5 @@
'use strict';
var path = require('path');
var leBinPath = require('homedir')() + '/.local/share/letsencrypt/bin/letsencrypt';
var LEP = require('letsencrypt-python');
var lep = LEP.create(leBinPath, { debug: true });
var conf = {
domains: process.argv[2]
, email: process.argv[3]
@ -18,29 +14,39 @@ if (!conf.domains || !conf.email || !conf.agree) {
return;
}
// backend-specific defaults
// Note: For legal reasons you should NOT set email or agreeTos as a default
var LE = require('../');
var path = require('path');
// backend-specific defaults will be passed through
// Note: Since agreeTos is a legal agreement, I would suggest not accepting it by default
var bkDefaults = {
webroot: true
, webrootPath: path.join(__dirname, '..', 'tests', 'acme-challenge')
webrootPath: path.join(__dirname, '..', 'tests', 'acme-challenge')
, fullchainTpl: '/live/:hostname/fullchain.pem'
, privkeyTpl: '/live/:hostname/privkey.pem'
, configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config')
, logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs')
, workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work')
, server: LEP.stagingServer
, server: LE.stagingServer
, text: true
};
var le = require('../').create(lep, bkDefaults, {
var leBinPath = require('homedir')() + '/.local/share/letsencrypt/bin/letsencrypt';
var LEB = require('../backends-python');
var backend = LEB.create(leBinPath, bkDefaults, { debug: true });
var le = LE.create(backend, bkDefaults, {
/*
setChallenge: function () {
setChallenge: function (hostnames, key, value, cb) {
// the python backend needs fs.watch implemented
// before this would work (and even then it would be difficult)
, getChallenge: function () {
}
, getChallenge: function (hostnames, key, cb) {
//
}
, sniRegisterCallback: function (args, certInfo, cb) {
}
, sniRegisterCallback: function () {
, registrationFailureCallback: function (args, certInfo, cb) {
what do to when a backgrounded registration fails
}
*/
});

205
index.js
View File

@ -1,28 +1,15 @@
'use strict';
// TODO handle www and no-www together somehow?
var PromiseA = require('bluebird');
var crypto = require('crypto');
var tls = require('tls');
var LE = module.exports;
LE.cacheCertInfo = function (args, certInfo, ipc, handlers) {
// Randomize by +(0% to 25%) to prevent all caches expiring at once
var rnd = (require('crypto').randomBytes(1)[0] / 255);
var memorizeFor = Math.floor(handlers.memorizeFor + ((handlers.memorizeFor / 4) * rnd));
var hostname = args.domains[0];
certInfo.context = tls.createSecureContext({
key: certInfo.key
, cert: certInfo.cert
//, ciphers // node's defaults are great
});
certInfo.duration = certInfo.duration || handlers.duration;
certInfo.loadedAt = Date.now();
certInfo.memorizeFor = memorizeFor;
ipc[hostname] = certInfo;
return ipc[hostname];
};
LE.liveServer = "https://acme-v01.api.letsencrypt.org/directory";
LE.stagingServer = "https://acme-staging.api.letsencrypt.org/directory";
LE.merge = function merge(defaults, args) {
var copy = {};
@ -37,39 +24,19 @@ LE.merge = function merge(defaults, args) {
return copy;
};
LE.create = function (letsencrypt, defaults, handlers) {
LE.create = function (backend, defaults, handlers) {
if (!handlers) { handlers = {}; }
if (!handlers.duration) { handlers.duration = 90 * 24 * 60 * 60 * 1000; }
if (!handlers.renewIn) { handlers.renewIn = 80 * 24 * 60 * 60 * 1000; }
if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; }
if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; }
if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; }
letsencrypt = PromiseA.promisifyAll(letsencrypt);
var fs = PromiseA.promisifyAll(require('fs'));
var utils = require('./utils');
// TODO move to backend-python.js
var registerAsync = PromiseA.promisify(function (args) {
return letsencrypt.registerAsync('certonly', args);
});
var fetchAsync = PromiseA.promisify(function (args) {
var hostname = args.domains[0];
var crtpath = defaults.configDir + defaults.fullchainTpl.replace(/:hostname/, hostname);
var privpath = defaults.configDir + defaults.privkeyTpl.replace(/:hostname/, hostname);
return PromiseA.all([
fs.readFileAsync(privpath, 'ascii')
, fs.readFileAsync(crtpath, 'ascii')
// stat the file, not the link
, fs.statAsync(crtpath, 'ascii')
]).then(function (arr) {
return {
key: arr[0] // privkey.pem
, cert: arr[1] // fullchain.pem
// TODO parse centificate
, renewedAt: arr[2].mtime.valueOf()
if (!handlers.sniRegisterCallback) {
handlers.sniRegisterCallback = function (args, cache, cb) {
// TODO when we have ECDSA, just do this automatically
cb(null, null);
};
});
});
defaults.webroot = true;
}
backend = PromiseA.promisifyAll(backend);
var utils = require('./utils');
//var attempts = {}; // should exist in master process only
var ipc = {}; // in-process cache
@ -80,10 +47,6 @@ LE.create = function (letsencrypt, defaults, handlers) {
// TODO check certs with setInterval?
//options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000);
function isCurrent(cache) {
return cache;
}
function sniCallback(hostname, cb) {
var args = LE.merge(defaults, {});
args.domains = [hostname];
@ -114,7 +77,7 @@ LE.create = function (letsencrypt, defaults, handlers) {
cb(null, cache.context);
}
if (isCurrent(cache)) {
if (cache) {
vazhdo();
return;
}
@ -151,7 +114,7 @@ LE.create = function (letsencrypt, defaults, handlers) {
}
, SNICallback: sniCallback
, sniCallback: sniCallback
, register: function (args, cb) {
, _registerHelper: function (args, cb) {
var copy = LE.merge(defaults, args);
var err;
@ -168,40 +131,83 @@ LE.create = function (letsencrypt, defaults, handlers) {
return;
}
return registerAsync(copy).then(function () {
console.log("[NLE]: begin registration");
return backend.registerAsync(copy).then(function () {
console.log("[NLE]: end registration");
// calls fetch because fetch calls cacheCertInfo
return le.fetch(args, cb);
}, cb);
});
}
, _fetchHelper: function (args, cb) {
return backend.fetchAsync(args).then(function (certInfo) {
if (!certInfo) {
cb(null, null);
return;
}
var now = Date.now();
// key, cert, issuedAt, lifetime, expiresAt
if (!certInfo.expiresAt) {
certInfo.expiresAt = certInfo.issuedAt + (certInfo.lifetime || handlers.lifetime);
}
if (!certInfo.lifetime) {
certInfo.lifetime = (certInfo.lifetime || handlers.lifetime);
}
// a pretty good hard buffer
certInfo.expiresAt -= (1 * 24 * 60 * 60 * 100);
certInfo = LE.cacheCertInfo(args, certInfo, ipc, handlers);
if (now > certInfo.bestIfUsedBy && !certInfo.timeout) {
// EXPIRING
if (now > certInfo.expiresAt) {
// EXPIRED
certInfo.renewTimeout = Math.floor(certInfo.renewTimeout / 2);
}
certInfo.timeout = setTimeout(function () {
le.register(args, cb);
}, certInfo.renewTimeout);
}
cb(null, certInfo.context);
}, cb);
}
, fetch: function (args, cb) {
var hostname = args.domains[0];
// TODO don't call now() every time because this is hot code
var now = Date.now();
var certInfo = ipc[hostname];
// TODO handle www and no-www together somehow?
var cached = ipc[hostname];
// TODO once ECDSA is available, wait for cert renewal if its due
if (certInfo) {
if (now > certInfo.bestIfUsedBy && !certInfo.timeout) {
// EXPIRING
if (now > certInfo.expiresAt) {
// EXPIRED
certInfo.renewTimeout = Math.floor(certInfo.renewTimeout / 2);
}
if (cached) {
cb(null, cached.context);
certInfo.timeout = setTimeout(function () {
le.register(args, cb);
}, certInfo.renewTimeout);
}
cb(null, certInfo.context);
if ((now - cached.loadedAt) < (cached.memorizeFor)) {
// not stale yet
if ((now - certInfo.loadedAt) < (certInfo.memorizeFor)) {
// these aren't stale, so don't fall through
return;
}
}
return fetchAsync(args).then(function (certInfo) {
if (certInfo) {
certInfo = LE.cacheCertInfo(args, certInfo, ipc, handlers);
cb(null, certInfo.context);
} else {
cb(null, null);
le._fetchHelper(args, cb);
}
}, cb);
}
, fetchOrRegister: function (args, cb) {
le.fetch(args, function (err, hit) {
, register: function (args, cb) {
// this may be run in a cluster environment
// in that case it should NOT check the cache
// but ensure that it has the most fresh copy
// before attempting a renew
le._fetchHelper(args, function (err, hit) {
var hostname = args.domains[0];
if (err) {
@ -213,10 +219,13 @@ LE.create = function (letsencrypt, defaults, handlers) {
return;
}
// TODO validate domains empirically before trying le
return registerAsync(args/*, opts*/).then(function () {
// wait at least n minutes
le.fetch(args, function (err, cache) {
return le._registerHelper(args, function (err) {
if (err) {
cb(err);
return;
}
le._fetchHelper(args, function (err, cache) {
if (cache) {
cb(null, cache.context);
return;
@ -229,14 +238,17 @@ LE.create = function (letsencrypt, defaults, handlers) {
console.error("[Error] Let's Encrypt failed:");
console.error(err.stack || new Error(err.message || err.toString()).stack);
// wasn't successful with lets encrypt, don't try again for n minutes
// wasn't successful with lets encrypt, don't automatically try again for 12 hours
// TODO what's the better way to handle this?
// failure callback?
ipc[hostname] = {
context: null
, renewedAt: Date.now()
, duration: (5 * 60 * 1000)
context: null // TODO default context
, issuedAt: Date.now()
, lifetime: (12 * 60 * 60 * 1000)
// , expiresAt: generated in next step
};
cb(null, ipc[hostname]);
cb(err, ipc[hostname]);
});
});
}
@ -244,3 +256,36 @@ LE.create = function (letsencrypt, defaults, handlers) {
return le;
};
LE.cacheCertInfo = function (args, certInfo, ipc, handlers) {
// TODO IPC via process and worker to guarantee no races
// rather than just "really good odds"
var hostname = args.domains[0];
var now = Date.now();
// Stagger randomly by plus 0% to 25% to prevent all caches expiring at once
var rnd1 = (crypto.randomBytes(1)[0] / 255);
var memorizeFor = Math.floor(handlers.memorizeFor + ((handlers.memorizeFor / 4) * rnd1));
// Stagger randomly to renew between n and 2n days before renewal is due
// this *greatly* reduces the risk of multiple cluster processes renewing the same domain at once
var rnd2 = (crypto.randomBytes(1)[0] / 255);
var bestIfUsedBy = certInfo.expiresAt - (handlers.renewWithin + Math.floor(handlers.renewWithin * rnd2));
// Stagger randomly by plus 0 to 5 min to reduce risk of multiple cluster processes
// renewing at once on boot when the certs have expired
var rnd3 = (crypto.randomBytes(1)[0] / 255);
var renewTimeout = Math.floor((5 * 60 * 1000) * rnd3);
certInfo.context = tls.createSecureContext({
key: certInfo.key
, cert: certInfo.cert
//, ciphers // node's defaults are great
});
certInfo.loadedAt = now;
certInfo.memorizeFor = memorizeFor;
certInfo.bestIfUsedBy = bestIfUsedBy;
certInfo.renewTimeout = renewTimeout;
ipc[hostname] = certInfo;
return ipc[hostname];
};