diff --git a/demo.js b/demo.js new file mode 100644 index 0000000..fd675a6 --- /dev/null +++ b/demo.js @@ -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"); + }); diff --git a/greenlock-express.js b/greenlock-express.js new file mode 100644 index 0000000..50ca472 --- /dev/null +++ b/greenlock-express.js @@ -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); +}; diff --git a/http-middleware.js b/http-middleware.js new file mode 100644 index 0000000..400f6dc --- /dev/null +++ b/http-middleware.js @@ -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) || ""; +}; diff --git a/https-middleware.js b/https-middleware.js new file mode 100644 index 0000000..8d45d76 --- /dev/null +++ b/https-middleware.js @@ -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( + "

Domain Fronting Error

" + + "

This connection was secured using TLS/SSL for '" + + (req.socket.servername || "").toLowerCase() + + "'

" + + "

The HTTP request specified 'Host: " + + safehost + + "', which is (obviously) different.

" + + "

Because this looks like a domain fronting attack, the connection has been terminated.

" + ); + 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; +}; diff --git a/index.js b/index.js deleted file mode 100644 index d1764ad..0000000 --- a/index.js +++ /dev/null @@ -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; -}; diff --git a/main.js b/main.js new file mode 100644 index 0000000..018481b --- /dev/null +++ b/main.js @@ -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(); +} diff --git a/master.js b/master.js new file mode 100644 index 0000000..5089263 --- /dev/null +++ b/master.js @@ -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(); + }); +}; diff --git a/servers.js b/servers.js new file mode 100644 index 0000000..3b79e6e --- /dev/null +++ b/servers.js @@ -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); + } +} diff --git a/single.js b/single.js new file mode 100644 index 0000000..79989bf --- /dev/null +++ b/single.js @@ -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; +}; diff --git a/sni.js b/sni.js new file mode 100644 index 0000000..0cb4216 --- /dev/null +++ b/sni.js @@ -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(".") + ); +} diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..37b6425 --- /dev/null +++ b/worker.js @@ -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 + }); +}