diff --git a/vhost-sni-server.js b/vhost-sni-server.js index 5cfb64c..acccf8f 100644 --- a/vhost-sni-server.js +++ b/vhost-sni-server.js @@ -1,48 +1,77 @@ 'use strict'; -var https = require('https') - , http = require('http') - , PromiseA = require('bluebird').Promise - , forEachAsync = require('foreachasync').forEachAsync.create(PromiseA) - , fs = require('fs') - , path = require('path') - , crypto = require('crypto') - , connect = require('connect') - , vhost = require('vhost') - , escapeRe = require('escape-string-regexp') +var https = require('https'); +var http = require('http'); +var PromiseA = require('bluebird').Promise; +var forEachAsync = require('foreachasync').forEachAsync.create(PromiseA); +var fs = require('fs'); +var path = require('path'); +var crypto = require('crypto'); +var connect = require('connect'); +var vhost = require('vhost'); +var escapeRe = require('escape-string-regexp'); // connect / express app - , app = connect() +var app = connect(); // SSL Server - , secureContexts = {} - , secureOpts - , secureServer - , securePort = /*process.argv[2] ||*/ 443 +var secureContexts = {}; +var secureOpts; +var secureServer; +var securePort = process.argv[2] || 443; // force SSL upgrade server - , insecureServer - , insecurePort = /*process.argv[3] ||*/ 80 +var insecureServer; +var insecurePort = process.argv[3] || 80; // the ssl domains I have // TODO read vhosts minus - , domains = fs.readdirSync(path.join(__dirname, 'vhosts')).filter(function (node) { +var domains = fs.readdirSync(path.join(__dirname, 'vhosts')).filter(function (node) { // not a hidden or private file return '.' !== node[0] && '_' !== node[0]; - }) - ; + }).map(function (apppath) { + var parts = apppath.split(/[#%]+/); + var hostname = parts.shift(); + var pathname = parts.join('/').replace(/\/+/g, '/').replace(/^\//, ''); + + return { + hostname: hostname + , pathname: pathname + , dirname: apppath + , isRoot: apppath === hostname + }; + }).sort(function (a, b) { + var hlen = b.hostname.length - a.hostname.length; + var plen = b.pathname.length - a.pathname.length; + + // A directory could be named example.com, example.com# example.com## + // to indicate order of preference (for API addons, for example) + var dlen = b.dirname.length - a.dirname.length; + if (!hlen) { + if (!plen) { + return dlen; + } + return plen; + } + return plen; + }); +var rootDomains = domains.filter(function (domaininfo) { + return domaininfo.isRoot; + }); +var domainMergeMap = {}; +var domainMerged = []; require('ssl-root-cas') .inject() ; -function getAppContext(domain) { - var localApp - ; +function getAppContext(domaininfo) { + var localApp; - localApp = require(path.join(__dirname, 'vhosts', domain, 'app.js')); + localApp = require(path.join(__dirname, 'vhosts', domaininfo.dirname, 'app.js')); if (localApp.create) { // TODO read local config.yml and pass it in + // TODO pass in websocket localApp = localApp.create(/*config*/); } if (!localApp.then) { @@ -52,20 +81,44 @@ function getAppContext(domain) { return localApp; } -forEachAsync(domains, function (domain) { - secureContexts[domain] = crypto.createCredentials({ - key: fs.readFileSync(path.join(__dirname, 'vhosts', domain, 'certs/server/my-server.key.pem')) - , cert: fs.readFileSync(path.join(__dirname, 'vhosts', domain, 'certs/server/my-server.crt.pem')) - , ca: fs.readdirSync(path.join(__dirname, 'vhosts', domain, 'certs/ca')).map(function (node) { - return fs.readFileSync(path.join(__dirname, 'vhosts', domain, 'certs/ca', node)); - }) - }).context; +function loadDummyCerts() { + var certsPath = path.join(__dirname, 'certs'); + var certs = { + key: fs.readFileSync(path.join(certsPath, 'server', 'dummy-server.key.pem')) + , cert: fs.readFileSync(path.join(certsPath, 'server', 'dummy-server.crt.pem')) + , ca: fs.readdirSync(path.join(certsPath, 'ca')).map(function (node) { + return fs.readFileSync(path.join(certsPath, 'ca', node)); + }) + }; + secureContexts.dummy = crypto.createCredentials(certs).context; + secureContexts.dummy.certs = certs; +} +loadDummyCerts(); - return getAppContext(domain).then(function (localApp) { - app.use(vhost('www.' + domain, localApp)); - app.use(vhost(domain, localApp)); - }); -}).then(function () { +function loadCerts(domaininfo) { + var certsPath = path.join(__dirname, 'vhosts', domaininfo.dirname, 'certs'); + + try { + var nodes = fs.readdirSync(path.join(certsPath, 'server')); + var keyNode = nodes.filter(function (node) { return /\.key\.pem$/.test(node); })[0]; + var crtNode = nodes.filter(function (node) { return /\.crt\.pem$/.test(node); })[0]; + + secureContexts[domaininfo.hostname] = crypto.createCredentials({ + key: fs.readFileSync(path.join(certsPath, 'server', keyNode)) + , cert: fs.readFileSync(path.join(certsPath, 'server', crtNode)) + , ca: fs.readdirSync(path.join(certsPath, 'ca')).map(function (node) { + return fs.readFileSync(path.join(certsPath, 'ca', node)); + }) + }).context; + } catch(err) { + // TODO Let's Encrypt / ACME HTTPS + console.error("[ERROR] Couldn't load HTTPS certs from '" + certsPath + "':"); + console.error(err); + secureContexts[domaininfo.hostname] = secureContexts.dummy; + } +} + +forEachAsync(rootDomains, loadCerts).then(function () { // fallback / default domain /* app.use('/', function (req, res) { @@ -73,104 +126,125 @@ forEachAsync(domains, function (domain) { res.end("

Hello, World... This isn't the domain you're looking for.

"); }); */ -}); - -//provide a SNICallback when you create the options for the https server -secureOpts = { - //SNICallback is passed the domain name, see NodeJS docs on TLS - SNICallback: function (domain) { - //console.log('SNI:', domain); - return secureContexts[domain]; - } - // fallback / default domain -, key: fs.readFileSync(path.join(__dirname, 'certs/server', 'dummy-server.key.pem')) -, cert: fs.readFileSync(path.join(__dirname, 'certs/server', 'dummy-server.crt.pem')) -, ca: fs.readdirSync(path.join(__dirname, 'certs/ca')).map(function (node) { - return fs.readFileSync(path.join(__dirname, 'certs/ca', node)); - }) -}; - -secureServer = https.createServer(secureOpts); -secureServer.on('request', app); -secureServer.listen(securePort, function () { - console.log("Listening on https://localhost:" + secureServer.address().port); -}); - -// TODO localhost-only server shutdown mechanism -// that closes all sockets, waits for them to finish, -// and then hands control over completely to respawned server - -// -// Redirect HTTP ot HTTPS -// -// This simply redirects from the current insecure location to the encrypted location -// -insecureServer = http.createServer(); -insecureServer.on('request', function (req, res) { - var insecureRedirects; - var host = req.headers.host || ''; - var url = req.url; - - // because I have domains for which I don't want to pay for SSL certs - insecureRedirects = require('./redirects.json').sort(function (a, b) { - var hlen = b.from.hostname.length - a.from.hostname.length; - var plen; - if (!hlen) { - plen = b.from.path.length - a.from.path.length; - return plen; + return forEachAsync(domains, function (domaininfo) { + // should order and group by longest domain, then longest path + if (!domainMergeMap[domaininfo.hostname]) { + // create an connect / express app exclusive to this domain + // TODO express?? + domainMergeMap[domaininfo.hostname] = { hostname: domaininfo.hostname, apps: connect() }; + domainMerged.push(domainMergeMap[domaininfo.hostname]); } - return hlen; - }).forEach(function (redirect) { - var origHost = host; - // TODO if '*' === hostname[0], omit '^' - host = host.replace( - new RegExp('^' + escapeRe(redirect.from.hostname)) - , redirect.to.hostname - ); - if (host === origHost) { - return; - } - url = url.replace( - new RegExp('^' + escapeRe(redirect.from.path)) - , redirect.to.path - ); + + return getAppContext(domaininfo).then(function (localApp) { + // Note: pathname should NEVER have a leading '/' on its own + // we always add it explicitly + domainMergeMap[domaininfo.hostname].apps.use('/' + domaininfo.pathname, localApp); + console.info('Loaded ' + domaininfo.hostname + ':' + securePort + domaininfo.pathname); + }); + }).then(function () { + domainMerged.forEach(function (domainApp) { + app.use(vhost(domainApp.hostname, domainApp.apps)); + app.use(vhost('www.' + domainApp.hostname, domainApp.apps)); + }); + }); +}).then(runServer); + +function runServer() { + //provide a SNICallback when you create the options for the https server + secureOpts = { + //SNICallback is passed the domain name, see NodeJS docs on TLS + SNICallback: function (domainname) { + //console.log('SNI:', domain); + return secureContexts[domainname] || secureContext.dummy; + } + // fallback / default dummy certs + , key: secureContexts.dummy.certs.key + , cert: secureContexts.dummy.certs.cert + , ca: secureContexts.dummy.certs.ca + }; + + secureServer = https.createServer(secureOpts); + secureServer.on('request', app); + secureServer.listen(securePort, function () { + console.log("Listening on https://localhost:" + secureServer.address().port); }); - var newLocation = 'https://' - + host.replace(/:\d+/, ':' + securePort) + url - ; + // TODO localhost-only server shutdown mechanism + // that closes all sockets, waits for them to finish, + // and then hands control over completely to respawned server - var metaRedirect = '' - + '\n' - + '\n' - + ' \n' - + ' \n' - + '\n' - + '\n' - + '

You requested an insecure resource. Please use this instead: \n' - + ' ' + newLocation + '

\n' - + '\n' - + '\n' - ; - - // DO NOT HTTP REDIRECT - /* - res.setHeader('Location', newLocation); - res.statusCode = 302; - */ - - // BAD NEWS BEARS // - // When people are experimenting with the API and posting tutorials - // they'll use cURL and they'll forget to prefix with https:// - // If we allow that, then many users will be sending private tokens - // and such with POSTs in clear text and, worse, it will work! - // To minimize this, we give browser users a mostly optimal experience, - // but people experimenting with the API get a message letting them know - // that they're doing it wrong and thus forces them to ensure they encrypt. - res.setHeader('Content-Type', 'text/html'); - res.end(metaRedirect); -}); -insecureServer.listen(insecurePort, function(){ - console.log("\nRedirecting all http traffic to https\n"); -}); + // Redirect HTTP ot HTTPS + // + // This simply redirects from the current insecure location to the encrypted location + // + insecureServer = http.createServer(); + insecureServer.on('request', function (req, res) { + var insecureRedirects; + var host = req.headers.host || ''; + var url = req.url; + + // because I have domains for which I don't want to pay for SSL certs + insecureRedirects = require('./redirects.json').sort(function (a, b) { + var hlen = b.from.hostname.length - a.from.hostname.length; + var plen; + if (!hlen) { + plen = b.from.path.length - a.from.path.length; + return plen; + } + return hlen; + }).forEach(function (redirect) { + var origHost = host; + // TODO if '*' === hostname[0], omit '^' + host = host.replace( + new RegExp('^' + escapeRe(redirect.from.hostname)) + , redirect.to.hostname + ); + if (host === origHost) { + return; + } + url = url.replace( + new RegExp('^' + escapeRe(redirect.from.path)) + , redirect.to.path + ); + }); + + var newLocation = 'https://' + + host.replace(/:\d+/, ':' + securePort) + url + ; + + var metaRedirect = '' + + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n' + + '\n' + + '

You requested an insecure resource. Please use this instead: \n' + + ' ' + newLocation + '

\n' + + '\n' + + '\n' + ; + + // DO NOT HTTP REDIRECT + /* + res.setHeader('Location', newLocation); + res.statusCode = 302; + */ + + // BAD NEWS BEARS + // + // When people are experimenting with the API and posting tutorials + // they'll use cURL and they'll forget to prefix with https:// + // If we allow that, then many users will be sending private tokens + // and such with POSTs in clear text and, worse, it will work! + // To minimize this, we give browser users a mostly optimal experience, + // but people experimenting with the API get a message letting them know + // that they're doing it wrong and thus forces them to ensure they encrypt. + res.setHeader('Content-Type', 'text/html'); + res.end(metaRedirect); + }); + insecureServer.listen(insecurePort, function(){ + console.log("\nRedirecting all http traffic to https\n"); + }); +} diff --git a/walnut.js b/walnut.js index 57bfe42..6189829 100644 --- a/walnut.js +++ b/walnut.js @@ -30,5 +30,6 @@ holepunch.run([ , 'prod.coolaj86.com' , 'production.coolaj86.com' ], ports).then(function () { + // TODO use as module require('./vhost-sni-server.js'); });