🔐 Free SSL, Free Wildcard SSL, and Fully Automated HTTPS for node.js, issued by Let's Encrypt v2 via ACME. Issues and PRs on Github.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

304 lines
9.1 KiB

9 years ago
'use strict';
// TODO handle www and no-www together somehow?
9 years ago
var PromiseA = require('bluebird');
var crypto = require('crypto');
9 years ago
var tls = require('tls');
9 years ago
9 years ago
var LE = module.exports;
LE.liveServer = "https://acme-v01.api.letsencrypt.org/directory";
LE.stagingServer = "https://acme-staging.api.letsencrypt.org/directory";
9 years ago
LE.merge = function merge(defaults, args) {
var copy = {};
Object.keys(defaults).forEach(function (key) {
copy[key] = defaults[key];
});
Object.keys(args).forEach(function (key) {
copy[key] = args[key];
});
return copy;
};
LE.create = function (backend, defaults, handlers) {
9 years ago
if (!handlers) { handlers = {}; }
if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; }
if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; }
9 years ago
if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; }
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);
9 years ago
var utils = require('./utils');
9 years ago
9 years ago
//var attempts = {}; // should exist in master process only
var ipc = {}; // in-process cache
9 years ago
var le;
9 years ago
9 years ago
// TODO check certs on initial load
// TODO expect that certs expire every 90 days
// TODO check certs with setInterval?
//options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000);
9 years ago
9 years ago
function sniCallback(hostname, cb) {
9 years ago
var args = LE.merge(defaults, {});
9 years ago
args.domains = [hostname];
9 years ago
9 years ago
le.fetch(args, function (err, cache) {
if (err) {
cb(err);
return;
9 years ago
}
9 years ago
// vazhdo is Albanian for 'continue'
function vazhdo(err, c2) {
if (err) {
cb(err);
return;
}
cache = c2 || cache;
if (!cache.context) {
cache.context = tls.createSecureContext({
key: cache.key // privkey.pem
, cert: cache.cert // fullchain.pem
//, ciphers // node's defaults are great
});
}
cb(null, cache.context);
9 years ago
}
if (cache) {
9 years ago
vazhdo();
return;
}
9 years ago
var args = LE.merge(defaults, { domains: [hostname] });
handlers.sniRegisterCallback(args, cache, vazhdo);
9 years ago
});
}
le = {
9 years ago
validate: function (hostnames, cb) {
9 years ago
// TODO check dns, etc
9 years ago
if ((!hostnames.length && hostnames.every(le.isValidDomain))) {
cb(new Error("node-letsencrypt: invalid hostnames: " + hostnames.join(',')));
return;
}
9 years ago
//
// IMPORTANT
//
// Before attempting a dynamic registration you need to validate that
//
// * these are hostnames that you expected to exist on the system
// * their A records currently point to this ip
// * this system's ip hasn't changed
//
// If you do not check these things, then someone could attack you
// and cause you, in return, to have your ip be rate-limit blocked
//
9 years ago
console.warn("[SECURITY WARNING]: node-letsencrypt: validate(hostnames, cb) NOT IMPLEMENTED");
cb(null, true);
9 years ago
}
, middleware: function () {
//console.log('[DEBUG] webrootPath', defaults.webrootPath);
var serveStatic = require('serve-static')(defaults.webrootPath, { dotfiles: 'allow' });
9 years ago
var prefix = '/.well-known/acme-challenge/';
return function (req, res, next) {
9 years ago
if (0 !== req.url.indexOf(prefix)) {
9 years ago
next();
return;
}
serveStatic(req, res, next);
9 years ago
};
}
, SNICallback: sniCallback
, sniCallback: sniCallback
, _registerHelper: function (args, cb) {
9 years ago
var copy = LE.merge(defaults, args);
var err;
9 years ago
if (!utils.isValidDomain(args.domains[0])) {
9 years ago
err = new Error("invalid domain");
err.code = "INVALID_DOMAIN";
cb(err);
return;
9 years ago
}
9 years ago
9 years ago
return le.validate(args.domains, function (err) {
if (err) {
cb(err);
return;
}
console.log("[NLE]: begin registration");
return backend.registerAsync(copy).then(function () {
console.log("[NLE]: end registration");
9 years ago
// calls fetch because fetch calls cacheCertInfo
return le.fetch(args, cb);
}, cb);
9 years ago
});
}
, _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);
}
9 years ago
, fetch: function (args, cb) {
var hostname = args.domains[0];
9 years ago
// TODO don't call now() every time because this is hot code
var now = Date.now();
var certInfo = 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);
}
certInfo.timeout = setTimeout(function () {
le.register(args, cb);
}, certInfo.renewTimeout);
}
cb(null, certInfo.context);
9 years ago
if ((now - certInfo.loadedAt) < (certInfo.memorizeFor)) {
// these aren't stale, so don't fall through
9 years ago
return;
}
9 years ago
}
9 years ago
le._fetchHelper(args, cb);
9 years ago
}
, 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) {
9 years ago
var hostname = args.domains[0];
if (err) {
cb(err);
return;
}
else if (hit) {
cb(null, hit);
9 years ago
return;
}
return le._registerHelper(args, function (err) {
if (err) {
cb(err);
return;
}
le._fetchHelper(args, function (err, cache) {
9 years ago
if (cache) {
cb(null, cache.context);
return;
}
9 years ago
// still couldn't read the certs after success... that's weird
9 years ago
cb(err, null);
9 years ago
});
9 years ago
}, function (err) {
console.error("[Error] Let's Encrypt failed:");
9 years ago
console.error(err.stack || new Error(err.message || err.toString()).stack);
9 years ago
// 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?
9 years ago
ipc[hostname] = {
context: null // TODO default context
, issuedAt: Date.now()
, lifetime: (12 * 60 * 60 * 1000)
// , expiresAt: generated in next step
9 years ago
};
9 years ago
cb(err, ipc[hostname]);
9 years ago
});
9 years ago
});
}
};
9 years ago
9 years ago
return le;
9 years ago
};
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];
};