standalone complete! (I think?)
This commit is contained in:
parent
449b15b00c
commit
b1fcb5b271
|
@ -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
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
205
index.js
|
@ -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,39 +24,19 @@ 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) {
|
||||||
var utils = require('./utils');
|
// TODO when we have ECDSA, just do this automatically
|
||||||
|
cb(null, null);
|
||||||
// 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()
|
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
});
|
backend = PromiseA.promisifyAll(backend);
|
||||||
defaults.webroot = true;
|
var utils = require('./utils');
|
||||||
|
|
||||||
//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
|
||||||
|
@ -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);
|
, register: function (args, cb) {
|
||||||
}
|
// this may be run in a cluster environment
|
||||||
, fetchOrRegister: function (args, cb) {
|
// in that case it should NOT check the cache
|
||||||
le.fetch(args, function (err, hit) {
|
// 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];
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue