From 6df0dc2f762845432405152f4e43009497073cf1 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 27 Oct 2019 01:19:44 -0600 Subject: [PATCH] some sni bugfixes --- demo.js | 10 +- http-middleware.js | 2 +- server.js | 325 --------------------------------------------- servers.js | 4 +- single.js | 31 ++++- sni.js | 10 ++ 6 files changed, 52 insertions(+), 330 deletions(-) delete mode 100644 server.js diff --git a/demo.js b/demo.js index fd675a6..db78c54 100644 --- a/demo.js +++ b/demo.js @@ -4,9 +4,9 @@ var Greenlock = require("./"); var greenlockOptions = { cluster: false, - maintainerEmail: "greenlock-test@rootprojects.org", + serverId: "bowie.local", servername: "foo-gl.test.utahrust.com", - serverId: "bowie.local" + maintainerEmail: "greenlock-test@rootprojects.org", /* manager: { @@ -14,6 +14,12 @@ var greenlockOptions = { dbUrl: "postgres://foo@bar:baz/quux" } */ + + challenges: { + "dns-01": { + module: "acme-dns-01-digitalocean" + } + } }; Greenlock.create(greenlockOptions) diff --git a/http-middleware.js b/http-middleware.js index 400f6dc..8f6efa3 100644 --- a/http-middleware.js +++ b/http-middleware.js @@ -46,7 +46,7 @@ function skipIfNeedBe(req, res, next, defaultApp, hostname) { } function respondWithGrace(res, result, hostname, token) { - var keyAuth = result.keyAuthorization; + var keyAuth = result && result.keyAuthorization; if (keyAuth && "string" === typeof keyAuth) { res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(keyAuth); diff --git a/server.js b/server.js deleted file mode 100644 index c2323c2..0000000 --- a/server.js +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env node -"use strict"; -/*global Promise*/ - -///////////////////////////////// -// an okay vhost + api example // -///////////////////////////////// - -// -// I run this on a few servers. It demonstrates dynamic virtual hosting + apis -// /srv/www -> static sites in plain folders -// ex: /srv/www/example.com -// -// /srv/api -> express apps -// ex: /srv/api/api.example.com -// - -var configpath = process.argv[2] || "./config.js"; -var config = require(configpath); -// The prefix where sites go by name. -// For example: whatever.com may live in /srv/www/whatever.com, thus /srv/www is our path - -var path = require("path"); -var fs = require("./lib/compat.js").fsAsync; -var finalhandler = require("finalhandler"); -var serveStatic = require("serve-static"); - -//var glx = require('greenlock-express') -var glx = require("./").create({ - version: "draft-11", // Let's Encrypt v2 is ACME draft 11 - - //, server: 'https://acme-staging-v02.api.letsencrypt.org/directory' - server: "https://acme-v02.api.letsencrypt.org/directory", // If at first you don't succeed, stop and switch to staging - // https://acme-staging-v02.api.letsencrypt.org/directory - - configDir: config.configDir, // You MUST have access to write to directory where certs - // are saved. ex: /home/foouser/.config/acme - - approveDomains: myApproveDomains, // Greenlock's wraps around tls.SNICallback. Check the - // domain name here and reject invalid ones - - servername: config.servername, - app: myVhostApp, // Any node-style http app (i.e. express, koa, hapi, rill) - - /* CHANGE TO A VALID EMAIL */ - email: config.email, // Email for Let's Encrypt account and Greenlock Security - agreeTos: true, // Accept Let's Encrypt ToS - //, communityMember: true // Join Greenlock to get important updates, no spam - - //, debug: true - store: require("greenlock-store-fs") -}); - -if (require.main === module) { - var server = glx.listen(80, 443); - server.on("listening", function() { - console.info(server.type + " listening on", server.address()); - }); -} - -function matchConfig(thing, domain) { - if (!thing) { - return false; - } - if (thing[domain]) { - return domain; - } - - var keys = Object.keys(thing); - var result = null; - keys.some(function(k) { - if ("*" !== k[0]) { - return; - } - - // "foo.whatever.com".endsWith("*.whatever.com".slice(1)) - if (domain.endsWith(k.slice(1).toLowerCase())) { - result = k; - return true; - } - }); - - return result; -} - -function myApproveDomains(opts) { - console.info("SNI:", opts.domain); - // In this example the filesystem is our "database". - // We check in /srv/www for whatever.com and if it exists, it's allowed - // SECURITY Greenlock validates opts.domains ahead-of-time so you don't have to - - var domains = []; - var original = opts.domain; - var bare = original.replace(/^(www|api)\./, ""); - var challenger = matchConfig(config.challenges, original); - if (challenger) { - opts.challenges = { - "dns-01": config.challenges[challenger] - }; - domains.push(challenger); - return approveThem(); - } - - if (matchConfig(config.proxy, original)) { - console.log("debug: found proxy for", original); - domains.push(original); - return approveThem(); - } - - function approveThem() { - console.info("Approved domains:", domains); - opts.domains = domains; - //opts.email = email; - opts.agreeTos = true; - // pick the shortest (bare) or latest (www. instead of api.) to be the subject - opts.subject = opts.domains.sort(function(a, b) { - var len = a.length - b.length; - if (0 !== len) { - return len; - } - if (a < b) { - return 1; - } else { - return -1; - } - })[0]; - - if (!opts.challenges) { - opts.challenges = {}; - } - opts.challenges["http-01"] = require("le-challenge-fs"); - //opts.challenges['dns-01'] = require('le-challenge-dns'); - - // explicitly set account id and certificate.id - opts.account = { id: opts.email }; - opts.certificate = { id: opts.subject }; - - return Promise.resolve(opts); - } - - // The goal here is to support both bare and www domains - // - // dns:example.com + fs:www.example.com => both - // dns:www.example.com + fs:example.com => both - // - // dns:api.example.com + fs:www.example.com => www.example.com - // dns:api.example.com + fs:example.com => example.com - // - // dns:example.com + fs:example.com => example.com - // dns:www.example.com + fs:www.example.com => www.example.com - return checkWwws(bare) - .then(function(hostname) { - // hostname is either example.com or www.example.com - domains.push(hostname); - if ("api." + bare !== original) { - if (!domains.includes(original)) { - domains.push(original); - } - } - }) - .catch(function() { - // ignore error - return null; - }) - .then(function() { - // check for api prefix - var apiname = bare; - if (domains.length) { - apiname = "api." + bare; - } - return checkApi(apiname) - .then(function(app) { - if (!app) { - return null; - } - domains.push(apiname); - }) - .catch(function() { - return null; - }); - }) - .then(function() { - // It's possible that example.com could have been requested, - // and not found, but api.example.com was found - if (!domains.includes(original)) { - return Promise.reject(new Error("no bare, www., or api. domain matching '" + opts.domain + "'")); - } - - return approveThem(); - }); -} -exports.myApproveDomains = myApproveDomains; - -function checkApi(hostname) { - var apipath = path.join(config.api, hostname); - var link = ""; - return fs - .stat(apipath) - .then(function(stats) { - if (stats.isDirectory()) { - return require(apipath); - } - return fs.readFile(apipath, "utf8").then(function(txt) { - var linkpath = txt.split("\n")[0]; - link = " => " + linkpath + " "; - return require(linkpath); - }); - }) - .catch(function(e) { - if ("ENOENT" === e.code) { - return null; - } - console.error(e); - throw new Error("rejecting '" + hostname + "' because '" + apipath + link + "' failed at require()"); - }); -} -exports.checkApi = checkApi; - -function checkWwws(_hostname) { - if (!_hostname) { - // SECURITY don't serve the whole config.srv - return Promise.reject(new Error("missing hostname")); - } - var hostname = _hostname; - var hostdir = path.join(config.srv, hostname); - // TODO could test for www/no-www both in directory - return fs - .readdir(hostdir) - .then(function() { - // TODO check for some sort of htaccess.json and use email in that - // NOTE: you can also change other options such as `challengeType` and `challenge` - // opts.challengeType = 'http-01'; - // opts.challenge = require('le-challenge-fs').create({}); - return hostname; - }) - .catch(function() { - if ("www." === hostname.slice(0, 4)) { - // Assume we'll redirect to non-www if it's available. - hostname = hostname.slice(4); - hostdir = path.join(config.srv, hostname); - return fs.readdir(hostdir).then(function() { - return hostname; - }); - } else { - // Or check and see if perhaps we should redirect non-www to www - hostname = "www." + hostname; - hostdir = path.join(config.srv, hostname); - return fs.readdir(hostdir).then(function() { - return hostname; - }); - } - }) - .catch(function() { - throw new Error("rejecting '" + _hostname + "' because '" + hostdir + "' could not be read"); - }); -} -exports.checkWwws = checkWwws; - -var httpProxy = require("http-proxy"); - -var proxy = httpProxy.createProxyServer({ - xfwd: true -}); - -proxy.on("error", function(req, res) { - res.statusCode = 500; - res.end("500: Server Error"); -}); - -function myVhostApp(req, res) { - req.on("error", function(err) { - console.error("HTTPS Request Network Connection Error:"); - console.error(err); - }); - - // this is protected by greenlock-express from domain fronting attacks - var host = req.headers.host; - // ex: example.com - // ex: example.com:4080 - console.log("debug: host is", host); - var domain = matchConfig(config.proxy, host); - if (domain) { - console.log("debug: forwarding to", config.proxy[domain]); - proxy.web(req, res, { target: config.proxy[domain] }); - return; - } - - // SECURITY greenlock pre-sanitizes hostnames to prevent unauthorized fs access so you don't have to - // (also: only domains approved above will get here) - console.info(""); - console.info(req.method, (req.headers.host || "") + req.url); - Object.keys(req.headers).forEach(function(key) { - console.info(key, req.headers[key]); - }); - - // We could cache wether or not a host exists for some amount of time - var fin = finalhandler(req, res); - return checkWwws(req.headers.host) - .then(function(hostname) { - if (hostname !== req.headers.host) { - res.statusCode = 302; - res.setHeader("Location", "https://" + hostname); - // SECURITY this is safe only because greenlock disallows invalid hostnames - res.end(""); - return; - } - var serve = serveStatic(path.join(config.srv, hostname), { redirect: true }); - serve(req, res, fin); - }) - .catch(function(err) { - return checkApi(req.headers.host) - .then(function(app) { - if (app) { - app(req, res); - return; - } - console.error("none found", err); - fin(); - }) - .catch(function(err) { - console.error("api crashed error", err); - fin(err); - }); - }); -} diff --git a/servers.js b/servers.js index 3b79e6e..7ac0d4a 100644 --- a/servers.js +++ b/servers.js @@ -60,7 +60,7 @@ Servers.create = function(greenlock, opts) { console.info("Listening on", plainAddr + ":" + plainPort, "for ACME challenges, and redirecting to HTTPS"); // TODO fetch greenlock.servername - var secureServer = servers.httpsServer(app); + var secureServer = servers.httpsServer({}, app); var secureAddr = "0.0.0.0"; var securePort = 443; secureServer.listen(securePort, secureAddr, function() { @@ -119,8 +119,10 @@ function wrapDefaultSniCallback(opts, greenlock, secureOpts) { function createSecureServer(secureOpts, fn) { var major = process.versions.node.split(".")[0]; + console.log("debug set SNICallback:", secureOpts); // TODO can we trust earlier versions as well? if (major >= 12) { + secureOpts.allowHTTP1 = true; return require("http2").createSecureServer(secureOpts, fn); } else { return require("https").createServer(secureOpts, fn); diff --git a/single.js b/single.js index 79989bf..bb1b751 100644 --- a/single.js +++ b/single.js @@ -8,8 +8,37 @@ var Greenlock = require("@root/greenlock"); Single.create = function(opts) { var greenlock = Greenlock.create(opts); + greenlock.getAcmeHttp01ChallengeResponse = function(opts) { + return greenlock.find({ servername: opts.servername }).then(function(sites) { + if (!sites.length) { + return null; + } + var site = sites[0]; + if (!site.challenges || !site.challenges["http-01"]) { + return null; + } + + var plugin; + try { + plugin = require(site.challenges["http-01"].module); + plugin = plugin.create(site.challenges["http-01"]); + } catch (e) { + console.error("error getting acme http-01 plugin"); + console.error(e); + return null; + } + + return plugin.get(opts).then(function(result) { + // TODO is this the right way? + var ch = (result && result.challenge) || result || {}; + return { + keyAuthorization: ch.keyAuthorization + }; + }); + }); + }; + var servers = Servers.create(greenlock, opts); - //var master = Master.create(opts); var single = { worker: function(fn) { diff --git a/sni.js b/sni.js index 0cb4216..c89defc 100644 --- a/sni.js +++ b/sni.js @@ -38,6 +38,7 @@ sni.create = function(opts, greenlock, secureOpts) { } function getSecureContext(servername, cb) { + //console.log("debug sni", servername); if ("string" !== typeof servername) { // this will never happen... right? but stranger things have... console.error("[sanity fail] non-string servername:", servername); @@ -47,6 +48,7 @@ sni.create = function(opts, greenlock, secureOpts) { var secureContext = getCachedContext(servername); if (secureContext) { + //console.log("debug sni got cached context", servername, getCachedMeta(servername)); cb(null, secureContext); return; } @@ -54,11 +56,13 @@ sni.create = function(opts, greenlock, secureOpts) { getFreshContext(servername) .then(function(secureContext) { if (secureContext) { + //console.log("debug sni got fresh context", servername, getCachedMeta(servername)); cb(null, secureContext); return; } // Note: this does not replace tlsSocket.setSecureContext() // as it only works when SNI has been sent + //console.log("debug sni got default context", servername, getCachedMeta(servername)); cb(null, getDefaultContext()); }) .catch(function(err) { @@ -66,6 +70,7 @@ sni.create = function(opts, greenlock, secureOpts) { err.context = "sni_callback"; } notify("error", err); + //console.log("debug sni error", servername, err); cb(err); }); } @@ -86,6 +91,7 @@ sni.create = function(opts, greenlock, secureOpts) { return null; } + // always renew in background if (!meta.refreshAt || Date.now() >= meta.refreshAt) { getFreshContext(servername).catch(function(e) { if (!e.context) { @@ -95,6 +101,9 @@ sni.create = function(opts, greenlock, secureOpts) { }); } + // under normal circumstances this would never be expired + // and, if it is expired, something is so wrong it's probably + // not worth wating for the renewal - it has probably failed return meta.secureContext; } @@ -113,6 +122,7 @@ sni.create = function(opts, greenlock, secureOpts) { // 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) { + console.log("debug matches", matches); var meta = getCachedMeta(servername); if (!meta) { meta = _cache[servername] = { secureContext: {} };