wip: API looks good, on to testing

This commit is contained in:
AJ ONeal 2019-10-26 23:52:19 -06:00
parent 9ab7844ea8
commit 0dd3641dc2
11 changed files with 837 additions and 334 deletions

30
demo.js Normal file
View File

@ -0,0 +1,30 @@
"use strict";
var Greenlock = require("./");
var greenlockOptions = {
cluster: false,
maintainerEmail: "greenlock-test@rootprojects.org",
servername: "foo-gl.test.utahrust.com",
serverId: "bowie.local"
/*
manager: {
module: "greenlock-manager-sequelize",
dbUrl: "postgres://foo@bar:baz/quux"
}
*/
};
Greenlock.create(greenlockOptions)
.worker(function(glx) {
console.info();
console.info("Hello from worker");
glx.serveApp(function(req, res) {
res.end("Hello, Encrypted World!");
});
})
.master(function() {
console.log("Hello from master");
});

29
greenlock-express.js Normal file
View File

@ -0,0 +1,29 @@
"use strict";
require("./lib/compat");
// Greenlock Express
var GLE = module.exports;
// opts.approveDomains(options, certs, cb)
GLE.create = function(opts) {
if (!opts) {
opts = {};
}
// just for ironic humor
["cloudnative", "cloudscale", "webscale", "distributed", "blockchain"].forEach(function(k) {
if (opts[k]) {
opts.cluster = true;
}
});
// we want to be minimal, and only load the code that's necessary to load
if (opts.cluster) {
if (require("cluster").isMaster) {
return require("./master.js").create(opts);
}
return require("./worker.js").create(opts);
}
return require("./single.js").create(opts);
};

102
http-middleware.js Normal file
View File

@ -0,0 +1,102 @@
"use strict";
var HttpMiddleware = module.exports;
var servernameRe = /^[a-z0-9\.\-]+$/i;
var challengePrefix = "/.well-known/acme-challenge/";
HttpMiddleware.create = function(gl, defaultApp) {
if (defaultApp && "function" !== typeof defaultApp) {
throw new Error("use greenlock.httpMiddleware() or greenlock.httpMiddleware(function (req, res) {})");
}
return function(req, res, next) {
var hostname = HttpMiddleware.sanitizeHostname(req);
req.on("error", function(err) {
explainError(gl, err, "http_01_middleware_socket", hostname);
});
if (skipIfNeedBe(req, res, next, defaultApp, hostname)) {
return;
}
var token = req.url.slice(challengePrefix.length);
gl.getAcmeHttp01ChallengeResponse({ type: "http-01", servername: hostname, token: token })
.then(function(result) {
respondWithGrace(res, result, hostname, token);
})
.catch(function(err) {
respondToError(gl, res, err, "http_01_middleware_challenge_response", hostname);
});
};
};
function skipIfNeedBe(req, res, next, defaultApp, hostname) {
if (!hostname || 0 !== req.url.indexOf(challengePrefix)) {
if ("function" === typeof defaultApp) {
defaultApp(req, res, next);
} else if ("function" === typeof next) {
next();
} else {
res.statusCode = 500;
res.end("[500] Developer Error: app.use('/', greenlock.httpMiddleware()) or greenlock.httpMiddleware(app)");
}
}
}
function respondWithGrace(res, result, hostname, token) {
var keyAuth = result.keyAuthorization;
if (keyAuth && "string" === typeof keyAuth) {
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(keyAuth);
return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: { message: "domain '" + hostname + "' has no token '" + token + "'." } }));
}
function explainError(gl, err, ctx, hostname) {
if (!err.servername) {
err.servername = hostname;
}
if (!err.context) {
err.context = ctx;
}
(gl.notify || gl._notify)("error", err);
return err;
}
function respondToError(gl, res, err, ctx, hostname) {
err = explainError(gl, err, ctx, hostname);
res.statusCode = 500;
res.end("Internal Server Error: See logs for details.");
}
HttpMiddleware.getHostname = function(req) {
return req.hostname || req.headers["x-forwarded-host"] || (req.headers.host || "");
};
HttpMiddleware.sanitizeHostname = function(req) {
// we can trust XFH because spoofing causes no ham in this limited use-case scenario
// (and only telebit would be legitimately setting XFH)
var servername = HttpMiddleware.getHostname(req)
.toLowerCase()
.replace(/:.*/, "");
try {
req.hostname = servername;
} catch (e) {
// read-only express property
}
if (req.headers["x-forwarded-host"]) {
req.headers["x-forwarded-host"] = servername;
}
try {
req.headers.host = servername;
} catch (e) {
// TODO is this a possible error?
}
return (servernameRe.test(servername) && -1 === servername.indexOf("..") && servername) || "";
};

133
https-middleware.js Normal file
View File

@ -0,0 +1,133 @@
"use strict";
var SanitizeHost = module.exports;
var HttpMiddleware = require("./http-middleware.js");
SanitizeHost.create = function(gl, app) {
return function(req, res, next) {
function realNext() {
if ("function" === typeof app) {
app(req, res);
} else if ("function" === typeof next) {
next();
} else {
res.statusCode = 500;
res.end("Error: no middleware assigned");
}
}
var hostname = HttpMiddleware.getHostname(req);
// Replace the hostname, and get the safe version
var safehost = HttpMiddleware.sanitizeHostname(req);
// if no hostname, move along
if (!hostname) {
realNext();
return;
}
// if there were unallowed characters, complain
if (safehost.length !== hostname.length) {
res.statusCode = 400;
res.end("Malformed HTTP Header: 'Host: " + hostname + "'");
return;
}
// Note: This sanitize function is also called on plain sockets, which don't need Domain Fronting checks
if (req.socket.encrypted) {
if (req.socket && "string" === typeof req.socket.servername) {
// Workaround for https://github.com/nodejs/node/issues/22389
if (!SanitizeHost._checkServername(safehost, req.socket)) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<h1>Domain Fronting Error</h1>" +
"<p>This connection was secured using TLS/SSL for '" +
(req.socket.servername || "").toLowerCase() +
"'</p>" +
"<p>The HTTP request specified 'Host: " +
safehost +
"', which is (obviously) different.</p>" +
"<p>Because this looks like a domain fronting attack, the connection has been terminated.</p>"
);
return;
}
}
/*
else if (safehost && !gl._skip_fronting_check) {
// We used to print a log message here, but it turns out that it's
// really common for IoT devices to not use SNI (as well as many bots
// and such).
// It was common for the log message to pop up as the first request
// to the server, and that was confusing. So instead now we do nothing.
//console.warn("no string for req.socket.servername," + " skipping fronting check for '" + safehost + "'");
//gl._skip_fronting_check = true;
}
*/
}
// carry on
realNext();
};
};
var warnDomainFronting = true;
var warnUnexpectedError = true;
SanitizeHost._checkServername = function(safeHost, tlsSocket) {
var servername = (tlsSocket.servername || "").toLowerCase();
// acceptable: older IoT devices may lack SNI support
if (!servername) {
return true;
}
// acceptable: odd... but acceptable
if (!safeHost) {
return true;
}
if (safeHost === servername) {
return true;
}
if ("function" !== typeof tlsSocket.getCertificate) {
// domain fronting attacks allowed
if (warnDomainFronting) {
// https://github.com/nodejs/node/issues/24095
console.warn(
"Warning: node " +
process.version +
" is vulnerable to domain fronting attacks. Please use node v11.2.0 or greater."
);
warnDomainFronting = false;
}
return true;
}
// connection established with servername and session is re-used for allowed name
// See https://github.com/nodejs/node/issues/24095
var cert = tlsSocket.getCertificate();
try {
// TODO optimize / cache?
// *should* always have a string, right?
// *should* always be lowercase already, right?
if (
(cert.subject.CN || "").toLowerCase() !== safeHost &&
!(cert.subjectaltname || "").split(/,\s+/).some(function(name) {
// always prefixed with "DNS:"
return safeHost === name.slice(4).toLowerCase();
})
) {
return false;
}
} catch (e) {
// not sure what else to do in this situation...
if (warnUnexpectedError) {
console.warn("Warning: encoutered error while performing domain fronting check: " + e.message);
warnUnexpectedError = false;
}
return true;
}
return false;
};

334
index.js
View File

@ -1,334 +0,0 @@
"use strict";
var PromiseA;
try {
PromiseA = require("bluebird");
} catch (e) {
PromiseA = global.Promise;
}
// opts.approveDomains(options, certs, cb)
module.exports.create = function(opts) {
// accept all defaults for greenlock.challenges, greenlock.store, greenlock.middleware
if (!opts._communityPackage) {
opts._communityPackage = "greenlock-express.js";
opts._communityPackageVersion = require("./package.json").version;
}
function explainError(e) {
console.error("Error:" + e.message);
if ("EACCES" === e.errno) {
console.error("You don't have prmission to access '" + e.address + ":" + e.port + "'.");
console.error('You probably need to use "sudo" or "sudo setcap \'cap_net_bind_service=+ep\' $(which node)"');
return;
}
if ("EADDRINUSE" === e.errno) {
console.error("'" + e.address + ":" + e.port + "' is already being used by some other program.");
console.error("You probably need to stop that program or restart your computer.");
return;
}
console.error(e.code + ": '" + e.address + ":" + e.port + "'");
}
function _createPlain(plainPort) {
if (!plainPort) {
plainPort = 80;
}
var parts = String(plainPort).split(":");
var p = parts.pop();
var addr = parts
.join(":")
.replace(/^\[/, "")
.replace(/\]$/, "");
var args = [];
var httpType;
var server;
var validHttpPort = parseInt(p, 10) >= 0;
if (addr) {
args[1] = addr;
}
if (!validHttpPort && !/(\/)|(\\\\)/.test(p)) {
console.warn("'" + p + "' doesn't seem to be a valid port number, socket path, or pipe");
}
var mw = greenlock.middleware.sanitizeHost(greenlock.middleware(require("redirect-https")()));
server = require("http").createServer(function(req, res) {
req.on("error", function(err) {
console.error("Insecure Request Network Connection Error:");
console.error(err);
});
mw(req, res);
});
httpType = "http";
return {
server: server,
listen: function() {
return new PromiseA(function(resolve, reject) {
args[0] = p;
args.push(function() {
if (!greenlock.servername) {
if (Array.isArray(greenlock.approvedDomains) && greenlock.approvedDomains.length) {
greenlock.servername = greenlock.approvedDomains[0];
}
if (Array.isArray(greenlock.approveDomains) && greenlock.approvedDomains.length) {
greenlock.servername = greenlock.approvedDomains[0];
}
}
if (!greenlock.servername) {
resolve(null);
return;
}
return greenlock
.check({ domains: [greenlock.servername] })
.then(function(certs) {
if (certs) {
return {
key: Buffer.from(certs.privkey, "ascii"),
cert: Buffer.from(certs.cert + "\r\n" + certs.chain, "ascii")
};
}
console.info(
"Fetching certificate for '%s' to use as default for HTTPS server...",
greenlock.servername
);
return new PromiseA(function(resolve, reject) {
// using SNICallback because all options will be set
greenlock.tlsOptions.SNICallback(greenlock.servername, function(err /*, secureContext*/) {
if (err) {
reject(err);
return;
}
return greenlock
.check({ domains: [greenlock.servername] })
.then(function(certs) {
resolve({
key: Buffer.from(certs.privkey, "ascii"),
cert: Buffer.from(certs.cert + "\r\n" + certs.chain, "ascii")
});
})
.catch(reject);
});
});
})
.then(resolve)
.catch(reject);
});
server.listen.apply(server, args).on("error", function(e) {
if (server.listenerCount("error") < 2) {
console.warn("Did not successfully create http server and bind to port '" + p + "':");
explainError(e);
process.exit(41);
}
});
});
}
};
}
function _create(port) {
if (!port) {
port = 443;
}
var parts = String(port).split(":");
var p = parts.pop();
var addr = parts
.join(":")
.replace(/^\[/, "")
.replace(/\]$/, "");
var args = [];
var httpType;
var server;
var validHttpPort = parseInt(p, 10) >= 0;
if (addr) {
args[1] = addr;
}
if (!validHttpPort && !/(\/)|(\\\\)/.test(p)) {
console.warn("'" + p + "' doesn't seem to be a valid port number, socket path, or pipe");
}
var https;
try {
https = require("spdy");
greenlock.tlsOptions.spdy = { protocols: ["h2", "http/1.1"], plain: false };
httpType = "http2 (spdy/h2)";
} catch (e) {
https = require("https");
httpType = "https";
}
var sniCallback = greenlock.tlsOptions.SNICallback;
greenlock.tlsOptions.SNICallback = function(domain, cb) {
sniCallback(domain, function(err, context) {
cb(err, context);
if (!context || server._hasDefaultSecureContext) {
return;
}
if (!domain) {
domain = greenlock.servername;
}
if (!domain) {
return;
}
return greenlock
.check({ domains: [domain] })
.then(function(certs) {
// ignore the case that check doesn't have all the right args here
// to get the same certs that it just got (eventually the right ones will come in)
if (!certs) {
return;
}
if (server.setSecureContext) {
// only available in node v11.0+
server.setSecureContext({
key: Buffer.from(certs.privkey, "ascii"),
cert: Buffer.from(certs.cert + "\r\n" + certs.chain, "ascii")
});
console.info("Using '%s' as default certificate", domain);
} else {
console.info("Setting default certificates dynamically requires node v11.0+. Skipping.");
}
server._hasDefaultSecureContext = true;
})
.catch(function(/*e*/) {
// this may be that the test.example.com was requested, but it's listed
// on the cert for demo.example.com which is in its own directory, not the other
//console.warn("Unusual error: couldn't get newly authorized certificate:");
//console.warn(e.message);
});
});
};
if (greenlock.tlsOptions.cert) {
server._hasDefaultSecureContext = true;
if (greenlock.tlsOptions.cert.toString("ascii").split("BEGIN").length < 3) {
console.warn(
"Invalid certificate file. 'tlsOptions.cert' should contain cert.pem (certificate file) *and* chain.pem (intermediate certificates) seperated by an extra newline (CRLF)"
);
}
}
var mw = greenlock.middleware.sanitizeHost(function(req, res) {
try {
greenlock.app(req, res);
} catch (e) {
console.error("[error] [greenlock.app] Your HTTP handler had an uncaught error:");
console.error(e);
try {
res.statusCode = 500;
res.end("Internal Server Error: [Greenlock] HTTP exception logged for user-provided handler.");
} catch (e) {
// ignore
// (headers may have already been sent, etc)
}
}
});
server = https.createServer(greenlock.tlsOptions, function(req, res) {
/*
// Don't do this yet
req.on("error", function(err) {
console.error("HTTPS Request Network Connection Error:");
console.error(err);
});
*/
mw(req, res);
});
server.type = httpType;
return {
server: server,
listen: function() {
return new PromiseA(function(resolve) {
args[0] = p;
args.push(function() {
resolve(/*server*/);
});
server.listen.apply(server, args).on("error", function(e) {
if (server.listenerCount("error") < 2) {
console.warn("Did not successfully create http server and bind to port '" + p + "':");
explainError(e);
process.exit(41);
}
});
});
}
};
}
// NOTE: 'greenlock' is just 'opts' renamed
var greenlock = require("greenlock").create(opts);
if (!opts.app) {
opts.app = function(req, res) {
res.end("Hello, World!\nWith Love,\nGreenlock for Express.js");
};
}
opts.listen = function(plainPort, port, fnPlain, fn) {
var server;
var plainServer;
// If there is only one handler for the `listening` (i.e. TCP bound) event
// then we want to use it as HTTPS (backwards compat)
if (!fn) {
fn = fnPlain;
fnPlain = null;
}
var obj1 = _createPlain(plainPort, true);
var obj2 = _create(port, false);
plainServer = obj1.server;
server = obj2.server;
server.then = obj1.listen().then(function(tlsOptions) {
if (tlsOptions) {
if (server.setSecureContext) {
// only available in node v11.0+
server.setSecureContext(tlsOptions);
console.info("Using '%s' as default certificate", greenlock.servername);
} else {
console.info("Setting default certificates dynamically requires node v11.0+. Skipping.");
}
server._hasDefaultSecureContext = true;
}
return obj2.listen().then(function() {
// Report plain http status
if ("function" === typeof fnPlain) {
fnPlain.apply(plainServer);
} else if (!fn && !plainServer.listenerCount("listening") && !server.listenerCount("listening")) {
console.info(
"[:" +
(plainServer.address().port || plainServer.address()) +
"] Handling ACME challenges and redirecting to " +
server.type
);
}
// Report h2/https status
if ("function" === typeof fn) {
fn.apply(server);
} else if (!server.listenerCount("listening")) {
console.info("[:" + (server.address().port || server.address()) + "] Serving " + server.type);
}
});
}).then;
server.unencrypted = plainServer;
return server;
};
opts.middleware.acme = function(opts) {
return greenlock.middleware.sanitizeHost(greenlock.middleware(require("redirect-https")(opts)));
};
opts.middleware.secure = function(app) {
return greenlock.middleware.sanitizeHost(app);
};
return greenlock;
};

36
main.js Normal file
View File

@ -0,0 +1,36 @@
"use strict";
// this is the stuff that should run in the main foreground process,
// whether it's single or master
var major = process.versions.node.split(".")[0];
var minor = process.versions.node.split(".")[1];
var _hasSetSecureContext = false;
var shouldUpgrade = false;
// TODO can we trust earlier versions as well?
if (major >= 12) {
_hasSetSecureContext = !!require("http2").createSecureServer({}, function() {}).setSecureContext;
} else {
_hasSetSecureContext = !!require("https").createServer({}, function() {}).setSecureContext;
}
// TODO document in issues
if (!_hasSetSecureContext) {
// TODO this isn't necessary if greenlock options are set with options.cert
console.warn("Warning: node " + process.version + " is missing tlsSocket.setSecureContext().");
console.warn(" The default certificate may not be set.");
shouldUpgrade = true;
}
if (major < 11 || (11 === major && minor < 2)) {
// https://github.com/nodejs/node/issues/24095
console.warn("Warning: node " + process.version + " is missing tlsSocket.getCertificate().");
console.warn(" This is necessary to guard against domain fronting attacks.");
shouldUpgrade = true;
}
if (shouldUpgrade) {
console.warn("Warning: Please upgrade to node v11.2.0 or greater.");
console.warn();
}

95
master.js Normal file
View File

@ -0,0 +1,95 @@
"use strict";
require("./main.js");
var Master = module.exports;
var cluster = require("cluster");
var os = require("os");
var Greenlock = require("@root/greenlock");
var pkg = require("./package.json");
Master.create = function(opts) {
var workers = [];
var resolveCb;
var readyCb;
var _kicked = false;
var packageAgent = pkg.name + "/" + pkg.version;
if ("string" === typeof opts.packageAgent) {
opts.packageAgent += " ";
} else {
opts.packageAgent = "";
}
opts.packageAgent += packageAgent;
var greenlock = Greenlock.create(opts);
var ready = new Promise(function(resolve) {
resolveCb = resolve;
}).then(function(fn) {
readyCb = fn;
});
function kickoff() {
if (_kicked) {
return;
}
_kicked = true;
console.log("TODO: start the workers and such...");
// handle messages from workers
workers.push(null);
ready.then(function(fn) {
// not sure what this API should be yet
fn({
//workers: workers.slice(0)
});
});
}
var master = {
worker: function() {
kickoff();
return master;
},
master: function(fn) {
if (readyCb) {
throw new Error("can't call master twice");
}
kickoff();
resolveCb(fn);
return master;
}
};
};
// opts.approveDomains(options, certs, cb)
GLE.create = function(opts) {
GLE._spawnWorkers(opts);
gl.tlsOptions = {};
return master;
};
function range(n) {
return new Array(n).join(",").split(",");
}
Master._spawnWorkers = function(opts) {
var numCpus = parseInt(process.env.NUMBER_OF_PROCESSORS, 10) || os.cpus().length;
var numWorkers = parseInt(opts.numWorkers, 10);
if (!numWorkers) {
if (numCpus <= 2) {
numWorkers = numCpus;
} else {
numWorkers = numCpus - 1;
}
}
return range(numWorkers).map(function() {
return cluster.fork();
});
};

128
servers.js Normal file
View File

@ -0,0 +1,128 @@
"use strict";
var Servers = module.exports;
var http = require("http");
var HttpMiddleware = require("./http-middleware.js");
var HttpsMiddleware = require("./https-middleware.js");
var sni = require("./sni.js");
Servers.create = function(greenlock, opts) {
var servers = {};
var _httpServer;
var _httpsServer;
function startError(e) {
explainError(e);
process.exit(1);
}
servers.httpServer = function(defaultApp) {
if (_httpServer) {
return _httpServer;
}
_httpServer = http.createServer(HttpMiddleware.create(opts.greenlock, defaultApp));
_httpServer.once("error", startError);
return _httpServer;
};
servers.httpsServer = function(secureOpts, defaultApp) {
if (_httpsServer) {
return _httpsServer;
}
if (!secureOpts) {
secureOpts = {};
}
_httpsServer = createSecureServer(
wrapDefaultSniCallback(opts, greenlock, secureOpts),
HttpsMiddleware.create(greenlock, defaultApp)
);
_httpsServer.once("error", startError);
return _httpsServer;
};
servers.serveApp = function(app) {
return new Promise(function(resolve, reject) {
if ("function" !== typeof app) {
reject(new Error("glx.serveApp(app) expects a node/express app in the format `function (req, res) { ... }`"));
return;
}
var plainServer = servers.httpServer(require("redirect-https")());
var plainAddr = "0.0.0.0";
var plainPort = 80;
plainServer.listen(plainPort, plainAddr, function() {
console.info("Listening on", plainAddr + ":" + plainPort, "for ACME challenges, and redirecting to HTTPS");
// TODO fetch greenlock.servername
var secureServer = servers.httpsServer(app);
var secureAddr = "0.0.0.0";
var securePort = 443;
secureServer.listen(securePort, secureAddr, function() {
console.info("Listening on", secureAddr + ":" + securePort, "for secure traffic");
plainServer.removeListener("error", startError);
secureServer.removeListener("error", startError);
resolve();
});
});
});
};
return servers;
};
function explainError(e) {
console.error();
console.error("Error: " + e.message);
if ("EACCES" === e.errno) {
console.error("You don't have prmission to access '" + e.address + ":" + e.port + "'.");
console.error('You probably need to use "sudo" or "sudo setcap \'cap_net_bind_service=+ep\' $(which node)"');
} else if ("EADDRINUSE" === e.errno) {
console.error("'" + e.address + ":" + e.port + "' is already being used by some other program.");
console.error("You probably need to stop that program or restart your computer.");
} else {
console.error(e.code + ": '" + e.address + ":" + e.port + "'");
}
console.error();
}
function wrapDefaultSniCallback(opts, greenlock, secureOpts) {
// I'm not sure yet if the original SNICallback
// should be called before or after, so I'm just
// going to delay making that choice until I have the use case
/*
if (!secureOpts.SNICallback) {
secureOpts.SNICallback = function(servername, cb) {
cb(null, null);
};
}
*/
if (secureOpts.SNICallback) {
console.warn();
console.warn("[warning] Ignoring the given tlsOptions.SNICallback function.");
console.warn();
console.warn(" We're very open to implementing support for this,");
console.warn(" we just don't understand the use case yet.");
console.warn(" Please open an issue to discuss. We'd love to help.");
console.warn();
}
secureOpts.SNICallback = sni.create(opts, greenlock, secureOpts);
return secureOpts;
}
function createSecureServer(secureOpts, fn) {
var major = process.versions.node.split(".")[0];
// TODO can we trust earlier versions as well?
if (major >= 12) {
return require("http2").createSecureServer(secureOpts, fn);
} else {
return require("https").createServer(secureOpts, fn);
}
}

26
single.js Normal file
View File

@ -0,0 +1,26 @@
"use strict";
require("./main.js");
var Single = module.exports;
var Servers = require("./servers.js");
var Greenlock = require("@root/greenlock");
Single.create = function(opts) {
var greenlock = Greenlock.create(opts);
var servers = Servers.create(greenlock, opts);
//var master = Master.create(opts);
var single = {
worker: function(fn) {
fn(servers);
return single;
},
master: function(/*fn*/) {
// ignore
//fn(master);
return single;
}
};
return single;
};

184
sni.js Normal file
View File

@ -0,0 +1,184 @@
"use strict";
var sni = module.exports;
var tls = require("tls");
var servernameRe = /^[a-z0-9\.\-]+$/i;
// a nice, round, irrational number - about every 6¼ hours
var refreshOffset = Math.round(Math.PI * 2 * (60 * 60 * 1000));
// and another, about 15 minutes
var refreshStagger = Math.round(Math.PI * 5 * (60 * 1000));
// and another, about 30 seconds
var smallStagger = Math.round(Math.PI * (30 * 1000));
//secureOpts.SNICallback = sni.create(opts, greenlock, secureOpts);
sni.create = function(opts, greenlock, secureOpts) {
var _cache = {};
var defaultServername = opts.servername || greenlock.servername;
if (secureOpts.cert) {
// Note: it's fine if greenlock.servername is undefined,
// but if the caller wants this to auto-renew, they should define it
_cache[defaultServername] = {
refreshAt: 0,
secureContext: tls.createSecureContext(secureOpts)
};
}
return getSecureContext;
function notify(ev, args) {
try {
// TODO _notify() or notify()?
(opts.notify || greenlock.notify || greenlock._notify)(ev, args);
} catch (e) {
console.error(e);
console.error(ev, args);
}
}
function getSecureContext(servername, cb) {
if ("string" !== typeof servername) {
// this will never happen... right? but stranger things have...
console.error("[sanity fail] non-string servername:", servername);
cb(new Error("invalid servername"), null);
return;
}
var secureContext = getCachedContext(servername);
if (secureContext) {
cb(null, secureContext);
return;
}
getFreshContext(servername)
.then(function(secureContext) {
if (secureContext) {
cb(null, secureContext);
return;
}
// Note: this does not replace tlsSocket.setSecureContext()
// as it only works when SNI has been sent
cb(null, getDefaultContext());
})
.catch(function(err) {
if (!err.context) {
err.context = "sni_callback";
}
notify("error", err);
cb(err);
});
}
function getCachedMeta(servername) {
var meta = _cache[servername];
if (!meta) {
if (!_cache[wildname(servername)]) {
return null;
}
}
return meta;
}
function getCachedContext(servername) {
var meta = getCachedMeta(servername);
if (!meta) {
return null;
}
if (!meta.refreshAt || Date.now() >= meta.refreshAt) {
getFreshContext(servername).catch(function(e) {
if (!e.context) {
e.context = "sni_background_refresh";
}
notify("error", e);
});
}
return meta.secureContext;
}
function getFreshContext(servername) {
var meta = getCachedMeta(servername);
if (!meta && !validServername(servername)) {
return Promise.resolve(null);
}
if (meta) {
// prevent stampedes
meta.refreshAt = Date.now() + randomRefreshOffset();
}
// TODO greenlock.get({ servername: servername })
// TODO don't get unknown certs at all, rely on auto-updates from greenlock
// Note: greenlock.renew() will return an existing fresh cert or issue a new one
return greenlock.renew({ servername: servername }).then(function(matches) {
var meta = getCachedMeta(servername);
if (!meta) {
meta = _cache[servername] = { secureContext: {} };
}
// prevent from being punked by bot trolls
meta.refreshAt = Date.now() + smallStagger;
// nothing to do
if (!matches.length) {
return null;
}
// we only care about the first one
var pems = matches[0].pems;
var site = matches[0].site;
var match = matches[0];
if (!pems || !pems.cert) {
// nothing to do
// (and the error should have been reported already)
return null;
}
meta = {
refreshAt: Date.now() + randomRefreshOffset(),
secureContext: tls.createSecureContext({
// TODO support passphrase-protected privkeys
key: pems.privkey,
cert: pems.cert + "\n" + pems.chain + "\n"
})
};
// copy this same object into every place
[match.altnames || site.altnames || [match.subject || site.subject]].forEach(function(altname) {
_cache[altname] = meta;
});
});
}
function getDefaultContext() {
return getCachedContext(defaultServername);
}
};
// whenever we need to know when to refresh next
function randomRefreshOffset() {
var stagger = Math.round(refreshStagger / 2) - Math.round(Math.random() * refreshStagger);
return refreshOffset + stagger;
}
function validServername(servername) {
// format and (lightly) sanitize sni so that users can be naive
// and not have to worry about SQL injection or fs discovery
servername = (servername || "").toLowerCase();
// hostname labels allow a-z, 0-9, -, and are separated by dots
// _ is sometimes allowed, but not as a "hostname", and not by Let's Encrypt ACME
// REGEX // https://www.codeproject.com/Questions/1063023/alphanumeric-validation-javascript-without-regex
return servernameRe.test(servername) && -1 === servername.indexOf("..");
}
function wildname(servername) {
return (
"*." +
servername
.split(".")
.slice(1)
.join(".")
);
}

74
worker.js Normal file
View File

@ -0,0 +1,74 @@
"use strict";
var Worker = module.exports;
Worker.create = function(opts) {
var greenlock = {
// rename presentChallenge?
getAcmeHttp01ChallengeResponse: presentChallenge,
notify: notifyMaster,
get: greenlockRenew
};
var worker = {
worker: function(fn) {
var servers = require("./servers.js").create(greenlock, opts);
fn(servers);
return worker;
},
master: function() {
// ignore
return worker;
}
};
return worker;
};
function greenlockRenew(args) {
return request("renew", {
servername: args.servername
});
}
function presentChallenge(args) {
return request("challenge-response", {
servername: args.servername,
token: args.token
});
}
function request(typename, msg) {
return new Promise(function(resolve, reject) {
var rnd = Math.random()
.slice(2)
.toString(16);
var id = "greenlock:" + rnd;
var timeout;
function getResponse(msg) {
if (msg.id !== id) {
return;
}
clearTimeout(timeout);
resolve(msg);
}
process.on("message", getResponse);
msg.id = msg;
msg.type = typename;
process.send(msg);
timeout = setTimeout(function() {
process.removeListener("message", getResponse);
reject(new Error("process message timeout"));
}, 30 * 1000);
});
}
function notifyMaster(ev, args) {
process.on("message", {
type: "notification",
event: ev,
parameters: args
});
}