From b1fcb5b2718c03e03f3cc24d0b0622c42f775157 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 13 Dec 2015 05:03:48 +0000 Subject: [PATCH] standalone complete! (I think?) --- README.md | 7 +- backends-python.js | 40 ++++++++ examples/standalone.js | 32 ++++--- index.js | 205 +++++++++++++++++++++++++---------------- 4 files changed, 190 insertions(+), 94 deletions(-) create mode 100644 backends-python.js diff --git a/README.md b/README.md index 9cf87e5..8e86dac 100644 --- a/README.md +++ b/README.md @@ -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 -------- diff --git a/backends-python.js b/backends-python.js new file mode 100644 index 0000000..f5eb43d --- /dev/null +++ b/backends-python.js @@ -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; +} diff --git a/examples/standalone.js b/examples/standalone.js index 7d24de6..6bf18f0 100644 --- a/examples/standalone.js +++ b/examples/standalone.js @@ -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 } */ }); diff --git a/index.js b/index.js index f3cc925..e1ad0c5 100644 --- a/index.js +++ b/index.js @@ -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,40 +24,20 @@ 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')); + if (!handlers.sniRegisterCallback) { + 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'); - // 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 ipc = {}; // in-process cache var le; @@ -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); - } - }, cb); + le._fetchHelper(args, 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]; +};