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 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 #### NOT YET PUBLISHED
@ -67,6 +67,7 @@ API
* `le.register({ domains, email, agreeTos, ... }, cb)` * `le.register({ domains, email, agreeTos, ... }, cb)`
* `le.fetch({domains, email, agreeTos, ... }, cb)` * `le.fetch({domains, email, agreeTos, ... }, cb)`
* `le.validate(domains, cb)` * `le.validate(domains, cb)`
* `le.registrationFailureCallback(err, args, certInfo, cb)`
### `LetsEncrypt.create(backend, bkDefaults, handlers)` ### `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)` Checks in-memory cache of certificates for `args.domains` and calls then calls `backend.fetch(args, cb)`
**after** merging `args` if necessary. **after** merging `args` if necessary.
### `le.registrationFailureCallback(err, args, certInfo, cb)`
Not yet implemented
Backends 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'; '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 = { var conf = {
domains: process.argv[2] domains: process.argv[2]
, email: process.argv[3] , email: process.argv[3]
@ -18,29 +14,39 @@ if (!conf.domains || !conf.email || !conf.agree) {
return; return;
} }
// backend-specific defaults var LE = require('../');
// Note: For legal reasons you should NOT set email or agreeTos as a default 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 = { var bkDefaults = {
webroot: true webrootPath: path.join(__dirname, '..', 'tests', 'acme-challenge')
, webrootPath: path.join(__dirname, '..', 'tests', 'acme-challenge')
, fullchainTpl: '/live/:hostname/fullchain.pem' , fullchainTpl: '/live/:hostname/fullchain.pem'
, privkeyTpl: '/live/:hostname/privkey.pem' , privkeyTpl: '/live/:hostname/privkey.pem'
, configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config') , configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config')
, logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') , logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs')
, workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') , workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work')
, server: LEP.stagingServer , server: LE.stagingServer
, text: true , 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 // the python backend needs fs.watch implemented
// before this would work (and even then it would be difficult) // 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'; 'use strict';
// TODO handle www and no-www together somehow?
var PromiseA = require('bluebird'); var PromiseA = require('bluebird');
var crypto = require('crypto');
var tls = require('tls'); var tls = require('tls');
var LE = module.exports; var LE = module.exports;
LE.cacheCertInfo = function (args, certInfo, ipc, handlers) { LE.liveServer = "https://acme-v01.api.letsencrypt.org/directory";
// Randomize by +(0% to 25%) to prevent all caches expiring at once LE.stagingServer = "https://acme-staging.api.letsencrypt.org/directory";
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.merge = function merge(defaults, args) { LE.merge = function merge(defaults, args) {
var copy = {}; var copy = {};
@ -37,40 +24,20 @@ LE.merge = function merge(defaults, args) {
return copy; return copy;
}; };
LE.create = function (letsencrypt, defaults, handlers) { LE.create = function (backend, defaults, handlers) {
if (!handlers) { handlers = {}; } if (!handlers) { handlers = {}; }
if (!handlers.duration) { handlers.duration = 90 * 24 * 60 * 60 * 1000; } if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; }
if (!handlers.renewIn) { handlers.renewIn = 80 * 24 * 60 * 60 * 1000; } if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; }
if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; } if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; }
letsencrypt = PromiseA.promisifyAll(letsencrypt); if (!handlers.sniRegisterCallback) {
var fs = PromiseA.promisifyAll(require('fs')); handlers.sniRegisterCallback = function (args, cache, cb) {
// TODO when we have ECDSA, just do this automatically
cb(null, null);
};
}
backend = PromiseA.promisifyAll(backend);
var utils = require('./utils'); 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()
};
});
});
defaults.webroot = true;
//var attempts = {}; // should exist in master process only //var attempts = {}; // should exist in master process only
var ipc = {}; // in-process cache var ipc = {}; // in-process cache
var le; var le;
@ -80,10 +47,6 @@ LE.create = function (letsencrypt, defaults, handlers) {
// TODO check certs with setInterval? // TODO check certs with setInterval?
//options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000); //options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000);
function isCurrent(cache) {
return cache;
}
function sniCallback(hostname, cb) { function sniCallback(hostname, cb) {
var args = LE.merge(defaults, {}); var args = LE.merge(defaults, {});
args.domains = [hostname]; args.domains = [hostname];
@ -114,7 +77,7 @@ LE.create = function (letsencrypt, defaults, handlers) {
cb(null, cache.context); cb(null, cache.context);
} }
if (isCurrent(cache)) { if (cache) {
vazhdo(); vazhdo();
return; return;
} }
@ -151,7 +114,7 @@ LE.create = function (letsencrypt, defaults, handlers) {
} }
, SNICallback: sniCallback , SNICallback: sniCallback
, sniCallback: sniCallback , sniCallback: sniCallback
, register: function (args, cb) { , _registerHelper: function (args, cb) {
var copy = LE.merge(defaults, args); var copy = LE.merge(defaults, args);
var err; var err;
@ -168,40 +131,83 @@ LE.create = function (letsencrypt, defaults, handlers) {
return; 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 // calls fetch because fetch calls cacheCertInfo
return le.fetch(args, cb); return le.fetch(args, cb);
}, 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) { , fetch: function (args, cb) {
var hostname = args.domains[0]; var hostname = args.domains[0];
// TODO don't call now() every time because this is hot code // TODO don't call now() every time because this is hot code
var now = Date.now(); var now = Date.now();
var certInfo = ipc[hostname];
// TODO handle www and no-www together somehow? // TODO once ECDSA is available, wait for cert renewal if its due
var cached = ipc[hostname]; if (certInfo) {
if (now > certInfo.bestIfUsedBy && !certInfo.timeout) {
// EXPIRING
if (now > certInfo.expiresAt) {
// EXPIRED
certInfo.renewTimeout = Math.floor(certInfo.renewTimeout / 2);
}
if (cached) { certInfo.timeout = setTimeout(function () {
cb(null, cached.context); le.register(args, cb);
}, certInfo.renewTimeout);
}
cb(null, certInfo.context);
if ((now - cached.loadedAt) < (cached.memorizeFor)) { if ((now - certInfo.loadedAt) < (certInfo.memorizeFor)) {
// not stale yet // these aren't stale, so don't fall through
return; return;
} }
} }
return fetchAsync(args).then(function (certInfo) { le._fetchHelper(args, cb);
if (certInfo) {
certInfo = LE.cacheCertInfo(args, certInfo, ipc, handlers);
cb(null, certInfo.context);
} else {
cb(null, null);
}
}, cb);
} }
, fetchOrRegister: function (args, cb) { , register: function (args, cb) {
le.fetch(args, function (err, hit) { // 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]; var hostname = args.domains[0];
if (err) { if (err) {
@ -213,10 +219,13 @@ LE.create = function (letsencrypt, defaults, handlers) {
return; return;
} }
// TODO validate domains empirically before trying le return le._registerHelper(args, function (err) {
return registerAsync(args/*, opts*/).then(function () { if (err) {
// wait at least n minutes cb(err);
le.fetch(args, function (err, cache) { return;
}
le._fetchHelper(args, function (err, cache) {
if (cache) { if (cache) {
cb(null, cache.context); cb(null, cache.context);
return; return;
@ -229,14 +238,17 @@ LE.create = function (letsencrypt, defaults, handlers) {
console.error("[Error] Let's Encrypt failed:"); console.error("[Error] Let's Encrypt failed:");
console.error(err.stack || new Error(err.message || err.toString()).stack); 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] = { ipc[hostname] = {
context: null context: null // TODO default context
, renewedAt: Date.now() , issuedAt: Date.now()
, duration: (5 * 60 * 1000) , 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; 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];
};