From 59a8f5235da90790a76247666dda3f1b83dde29c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 18 Feb 2015 23:06:56 -0700 Subject: [PATCH] lazy loading walnut apps --- .gitignore | 30 ++++ lib/insecure-server.js | 88 ++++++++++ lib/vhost-sni-server.js | 286 +++++++++++++++++++++++++++++++ package.json | 11 +- snippets/make-encrypted-disk.sh | 38 ++++ snippets/test-acme.js | 24 +++ snippets/test-bind.js | 47 +++++ snippets/test-mounts.js | 71 ++++++++ snippets/test-mv-redirects.js | 10 ++ snippets/test-netroute.js | 7 + snippets/test-redirect.js | 59 +++++++ snippets/test-upnp-igd-search.js | 52 ++++++ vhost-sni-server.js | 277 ------------------------------ walnut.js | 85 +++++---- 14 files changed, 772 insertions(+), 313 deletions(-) create mode 100644 .gitignore create mode 100644 lib/insecure-server.js create mode 100644 lib/vhost-sni-server.js create mode 100644 snippets/make-encrypted-disk.sh create mode 100644 snippets/test-acme.js create mode 100644 snippets/test-bind.js create mode 100644 snippets/test-mounts.js create mode 100644 snippets/test-mv-redirects.js create mode 100644 snippets/test-netroute.js create mode 100644 snippets/test-redirect.js create mode 100644 snippets/test-upnp-igd-search.js delete mode 100644 vhost-sni-server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..573fa1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.*.sw* + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Commenting this out is preferred by some people, see +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- +node_modules + +# Users Environment Variables +.lock-wscript diff --git a/lib/insecure-server.js b/lib/insecure-server.js new file mode 100644 index 0000000..aeaf8e8 --- /dev/null +++ b/lib/insecure-server.js @@ -0,0 +1,88 @@ +'use strict'; + +var http = require('http'); +var escapeRe = require('escape-string-regexp'); +var insecureServer; + +module.exports.create = function (securePort, insecurePort, redirects) { + function redirectHttps(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 = redirects.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); + } + + // 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', redirectHttps); + insecureServer.listen(insecurePort, function(){ + console.log("\nRedirecting all http traffic to https\n"); + }); +}; diff --git a/lib/vhost-sni-server.js b/lib/vhost-sni-server.js new file mode 100644 index 0000000..c51e82c --- /dev/null +++ b/lib/vhost-sni-server.js @@ -0,0 +1,286 @@ +'use strict'; + +var https = require('https'); +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'); + +module.exports.create = function (securePort, certsPath, vhostsdir) { + // connect / express app + var app = connect(); + + // SSL Server + var secureContexts = {}; + var dummyCerts; + var secureOpts; + var secureServer; + + // the ssl domains I have + // TODO read vhosts minus + var domains = fs.readdirSync(vhostsdir).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 = []; + + function loadDummyCerts() { + 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')).filter(function (node) { + return /crt\.pem$/.test(node); + }).map(function (node) { + console.log('[Add CA]', node); + return fs.readFileSync(path.join(certsPath, 'ca', node)); + }) + }; + return certs + } + dummyCerts = loadDummyCerts(); + + function createSecureContext(certs) { + // workaround for v0.12 / v1.2 backwards compat + try { + return require('tls').createSecureContext(certs); + } catch(e) { + return require('crypto').createCredentials(certs).context; + } + } + secureContexts.dummy = createSecureContext(dummyCerts); + + function getAppContext(domaininfo) { + var localApp; + + try { + // TODO live reload required modules + localApp = require(path.join(vhostsdir, 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) { + return getDummyAppContext(null, "[ERROR] no app was returned by app.js for " + domaininfo.driname); + } + } + if (!localApp.then) { + localApp = PromiseA.resolve(localApp); + } else { + return localApp.catch(function (e) { + return getDummyAppContext(e, "[ERROR] initialization failed during create() for " + domaininfo.dirname); + }); + } + } catch(e) { + localApp = getDummyAppContext(e, "[ERROR] could not load app.js for " + domaininfo.dirname); + localApp = PromiseA.resolve(localApp); + + return localApp; + } + + return localApp; + } + + function loadDummyCerts() { + 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')).filter(function (node) { + return /crt\.pem$/.test(node); + }).map(function (node) { + console.log('[log dummy ca]', node); + return fs.readFileSync(path.join(certsPath, 'ca', node)); + }) + }; + secureContexts.dummy = crypto.createCredentials(certs).context; + dummyCerts = certs; + } + loadDummyCerts(); + + function loadCerts(domainname) { + // TODO make async + // WARNING: This must be SYNC until we KNOW we're not going to be running on v0.10 + // Also, once we load Let's Encrypt, it's lights out for v0.10 + + var certsPath = path.join(vhostsdir, domainname, '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]; + var secOpts = { + key: fs.readFileSync(path.join(certsPath, 'server', keyNode)) + , cert: fs.readFileSync(path.join(certsPath, 'server', crtNode)) + } + + if (fs.existsSync(path.join(certsPath, 'ca'))) { + secOpts.ca = fs.readdirSync(path.join(certsPath, 'ca')).filter(function (node) { + console.log('[log ca]', node); + return /crt\.pem$/.test(node); + }).map(function (node) { + return fs.readFileSync(path.join(certsPath, 'ca', node)); + }); + } + } catch(err) { + // TODO Let's Encrypt / ACME HTTPS + console.error("[ERROR] Couldn't READ HTTPS certs from '" + certsPath + "':"); + // this will be a simple file-read error + console.error(err.message); + return null; + } + + try { + secureContexts[domainname] = crypto.createCredentials(secOpts).context; + } catch(err) { + console.error("[ERROR] Certificates in '" + certsPath + "' could not be used:"); + console.error(err); + return null; + } + + return secureContexts[domainname]; + } + + app.use(function (req, res, next) { + console.log('[log] request for ' + req.headers.host + req.url); + next(); + }); + + // TODO load these once the server has started + // return forEachAsync(rootDomains, loadCerts); + return forEachAsync(domains, function (domaininfo) { + var appContext; + + // 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]); + } + + domainMergeMap[domaininfo.hostname].apps.use( + '/' + domaininfo.pathname + , function (req, res, next) { + if (appContext) { + console.log('[log] has appContext'); + appContext(req, res, next); + return; + } + + console.log('[log] no appContext'); + getAppContext(domaininfo).then(function (localApp) { + // Note: pathname should NEVER have a leading '/' on its own + // we always add it explicitly + try { + domainMergeMap[domaininfo.hostname].apps.use('/' + domaininfo.pathname, localApp); + console.info('Loaded ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname); + appContext = localApp; + appContext(req, res, next); + } catch(e) { + console.error('[ERROR] ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname); + console.error(e); + res.send('{ "error": { "message": "[ERROR] could not load ' + + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname + + 'or default error app." } }'); + } + }); + + } + ); + + return PromiseA.resolve(); + }).then(function () { + domainMerged.forEach(function (domainApp) { + console.log('[log] merged ' + domainApp.hostname); + 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 = { + // fallback / default dummy certs + key: dummyCerts.key + , cert: dummyCerts.cert + , ca: dummyCerts.ca + }; + + function addSniWorkaroundCallback() { + //SNICallback is passed the domain name, see NodeJS docs on TLS + secureOpts.SNICallback = function (domainname, cb) { + console.log('[log] SNI:', domainname); + + var secureContext = secureContexts[domainname] + || loadCerts(domainname) + || secureContexts.dummy + //|| createSecureContext(dummyCerts) + //|| createSecureContext(loadDummyCerts()) + ; + + if (!secureContext) { + // testing with shared dummy + //secureContext = secureContexts.dummy; + // testing passing bad argument + //secureContext = createSecureContext(loadDummyCerts); + // testing with fresh dummy + secureContext = createSecureContext(loadDummyCerts()); + } + + console.log('[log]', secureContext); + + // workaround for v0.12 / v1.2 backwards compat bug + if ('function' === typeof cb) { + console.log('using sni callback callback'); + cb(null, secureContext); + } else { + console.log('NOT using sni callback callback'); + return secureContext; + } + }; + } + addSniWorkaroundCallback(); + + secureServer = https.createServer(secureOpts); + secureServer.on('request', function (req, res) { + console.log('[log] request'); + app(req, res); + }); + secureServer.listen(securePort, function () { + console.log("Listening on https://localhost:" + secureServer.address().port); + }); + + return PromiseA.resolve(); + } +} diff --git a/package.json b/package.json index e7bea6a..b09c121 100644 --- a/package.json +++ b/package.json @@ -37,15 +37,18 @@ }, "homepage": "https://github.com/Daplie/walnut", "dependencies": { - "bluebird": "^2.9.3", - "check-ip-address": "^1.0.0", + "bluebird": "^2.9.9", + "check-ip-address": "^1.1.0", "cli": "^0.6.5", "connect": "^3.3.4", "escape-string-regexp": "^1.0.2", "express": "^4.11.2", "foreachasync": "^5.0.5", - "request": "^2.51.0", + "nat-pmp": "0.0.3", + "node-acme": "0.0.1", + "request": "^2.53.0", "ssl-root-cas": "^1.1.7", - "vhost": "^3.0.0" + "vhost": "^3.0.0", + "xml2js": "^0.4.5" } } diff --git a/snippets/make-encrypted-disk.sh b/snippets/make-encrypted-disk.sh new file mode 100644 index 0000000..6ec491b --- /dev/null +++ b/snippets/make-encrypted-disk.sh @@ -0,0 +1,38 @@ +#!/bin/bash +mkdir /mnt/data +mount /dev/sda1 /mnt/data +fallocate -l 100G /mnt/data/WALNUT_ENCRYPTED.virtual.disk +apt-get update +apt-get install --yes cryptsetup +cryptsetup -y luksFormat /mnt/data/WALNUT_ENCRYPTED.virtual.disk +# you'll be asked to type YES in all caps +# Then you'll be asked for a passphrase + +file /mnt/data/WALNUT_ENCRYPTED.virtual.disk + +cryptsetup luksOpen /mnt/data/WALNUT_ENCRYPTED.virtual.disk WALNUT_ENCRYPTED +# you'll be asked for your passphrase + +mkfs.ext4 -j /dev/mapper/WALNUT_ENCRYPTED +mkdir /mnt/WALNUT_ENCRYPTED +mount /dev/mapper/WALNUT_ENCRYPTED /mnt/WALNUT_ENCRYPTED + +#pi@pi /s/walnut> time sudo mv /mnt/WALNUT_ENCRYPTED/vhosts/ /mnt/data/vhosts +#0.49user 4.02system 0:18.60elapsed 24%CPU (0avgtext+0avgdata 2812maxresident)k +#71160inputs+66152outputs (1major+455minor)pagefaults 0swaps +#pi@pi /s/walnut> time sudo rsync -a /mnt/data/vhosts /mnt/WALNUT_ENCRYPTED/vhosts +#2.75user 5.93system 0:22.03elapsed 39%CPU (0avgtext+0avgdata 5200maxresident)k +#54816inputs+66152outputs (3major+2786minor)pagefaults 0swaps +#pi@pi /s/walnut> time sudo rsync -a /mnt/data/vhosts /mnt/data/vhosts-2 +#2.64user 5.98system 0:13.36elapsed 64%CPU (0avgtext+0avgdata 5364maxresident)k +#44416inputs+66152outputs (1major+3059minor)pagefaults 0swaps +#pi@pi /s/walnut> time sudo rsync -a /mnt/WALNUT_ENCRYPTED/vhosts /mnt/WALNUT_ENCRYPTED/vhosts-2 +#2.48user 6.19system 0:30.81elapsed 28%CPU (0avgtext+0avgdata 5328maxresident)k +#66264inputs+66152outputs (3major+2683minor)pagefaults 0swaps + +#pi@pi /s/walnut> time sudo rm -rf /mnt/data/vhosts* +#0.02user 0.04system 0:00.21elapsed 28%CPU (0avgtext+0avgdata 2804maxresident)k +#120inputs+0outputs (3major+372minor)pagefaults 0swaps +#pi@pi /s/walnut> time sudo rm -rf /mnt/WALNUT_ENCRYPTED/vhosts-2/ +#0.07user 0.74system 0:00.86elapsed 93%CPU (0avgtext+0avgdata 2768maxresident)k +#0inputs+0outputs (0major+402minor)pagefaults 0swaps diff --git a/snippets/test-acme.js b/snippets/test-acme.js new file mode 100644 index 0000000..05ebc8a --- /dev/null +++ b/snippets/test-acme.js @@ -0,0 +1,24 @@ +var acme = require("node-acme"); +var acmeServer = "www.letsencrypt-demo.org"; +var desiredIdentifier = "testssl.coolaj86.com"; +var authzURL = "https://" + acmeServer + "/acme/new-authz"; +var certURL = "https://" + acmeServer + "/acme/new-cert"; + +acme.getMeACertificate(authzURL, certURL, desiredIdentifier, function(x) { + console.log("Result of getMeACertificate:"); + console.log(x); + /* + if (acmeServer.match(/localhost/)) { + server.close(); + } + */ +}); + +/* +if (acmeServer.match(/localhost/)) { + // TODO for internal peers? + acme.enableLocalUsage(); +} +*/ + + diff --git a/snippets/test-bind.js b/snippets/test-bind.js new file mode 100644 index 0000000..684a569 --- /dev/null +++ b/snippets/test-bind.js @@ -0,0 +1,47 @@ +'use strict'; + +var dgram = require('dgram') + , fs = require('fs') + , socket + , ssdpPort = 1900 + , sourcePort = 61900 + , ssdpAddress = '239.255.255.250' + , myIface = '192.168.1.4' + , mySt = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' + ; + +function broadcastSsdp() { + var query + ; + + query = new Buffer( + 'M-SEARCH * HTTP/1.1\r\n' + + 'HOST: ' + ssdpAddress + ':' + ssdpPort + '\r\n' + + 'MAN: "ssdp:discover"\r\n' + + 'MX: 1\r\n' + + 'ST: ' + mySt + '\r\n' + + '\r\n' + ); + fs.writeFileSync('upnp-search.txt', query, null); + + // Send query on each socket + socket.send(query, 0, query.length, ssdpPort, ssdpAddress); +} + +// TODO test interface.family === 'IPv4' +socket = dgram.createSocket('udp4'); +socket.on('listening', function () { + console.log('socket ready...'); + console.log(myIface + ':' + ssdpPort); + + broadcastSsdp(); +}); +socket.on('message', function (chunk, info) { + var message = chunk.toString(); + console.log('[incoming] UDP message'); + console.log(message); + console.log(info); +}); + +console.log('binding to', sourcePort); +socket.bind(sourcePort, myIface); diff --git a/snippets/test-mounts.js b/snippets/test-mounts.js new file mode 100644 index 0000000..085a1c1 --- /dev/null +++ b/snippets/test-mounts.js @@ -0,0 +1,71 @@ +// characters that generally can't be used in a url: # % +// more: @ ! $ & +// Have special meaning to some FSes: : \ / +function methodA(apps) { + apps.map(function (apppath) { + var parts = apppath.split(/[#%]+/); + var hostname = parts.shift(); + var pathname = parts.join('/'); + return [hostname, pathname]; + }).sort(function (a, b) { + var hlen = b[0].length - a[0].length; + var plen = plen = b[1].length - a[1].length; + if (!plen) { + return hlen; + } + return plen; + }).forEach(function (pair, i) { + // should print ordered by longest path, longest domain + console.log('app.use("/' + pair[1] + '", vhost("' + pair[0] + '"), app' + i + ')'); + }); + console.log('\n'); +} + +function methodB(apps) { + var mergeMap = {}; + var merged = []; + + apps.map(function (apppath) { + var parts = apppath.split(/[#%]+/); + var hostname = parts.shift(); + var pathname = parts.join('/'); + + return [hostname, pathname]; + }).sort(function (a, b) { + var hlen = b[0].length - a[0].length; + var plen = plen = b[1].length - a[1].length; + if (!hlen) { + return plen; + } + return plen; + }).forEach(function (pair, i) { + var apps; + var hostname = pair[0]; + var pathname = pair[1]; + + // should order and group by longest domain, then longest path + if (!mergeMap[hostname]) { + mergeMap[hostname] = { hostname: hostname, apps: 'express()' }; + merged.push(mergeMap[hostname]); + } + + mergeMap[hostname].apps += '.use("/' + pathname + '", app' + i + ')'; + }); + + console.log('\n'); + merged.forEach(function (vhost) { + console.log("app.use(vhost('" + vhost.hostname + "', " + vhost.apps + ")"); + }); +} + +var apps; +apps = [ + 'coolaj86.com' +, 'coolaj86.com#demos#tel-carrier' +, 'blog.coolaj86.com#demos#tel-carrier' +, 'blog.coolaj86.com%social' +, 'blog.coolaj86.com' +]; + +methodA(apps); +methodB(apps); diff --git a/snippets/test-mv-redirects.js b/snippets/test-mv-redirects.js new file mode 100644 index 0000000..db66437 --- /dev/null +++ b/snippets/test-mv-redirects.js @@ -0,0 +1,10 @@ +var redirects = require('./redirects.json'); + +redirects.forEach(function (r) { + var frompath = "'" + r.from.hostname + r.from.path + "'"; + var topath = "'" + r.to.hostname + r.to.path.replace(/\//g, '#') + "'"; + + if (frompath !== topath) { + console.log("mv", frompath, " ", topath); + } +}); diff --git a/snippets/test-netroute.js b/snippets/test-netroute.js new file mode 100644 index 0000000..1c890a8 --- /dev/null +++ b/snippets/test-netroute.js @@ -0,0 +1,7 @@ +'use strict'; + +console.log( + require('netroute') +, require('netroute').getInfo() +, require('netroute').getGateway() +); diff --git a/snippets/test-redirect.js b/snippets/test-redirect.js new file mode 100644 index 0000000..dfe8290 --- /dev/null +++ b/snippets/test-redirect.js @@ -0,0 +1,59 @@ +'use strict'; + +var escapeRe = require('escape-string-regexp'); + +function redirect(host, url) { + var insecureRedirects; + // because I have domains for which I don't want to pay for SSL certs + insecureRedirects = [ + { "from": { "hostname": "coolaj86.org" , "path": "" } + , "to": { "hostname": "coolaj86.com", "path": "" } + } + , { "from": { "hostname": "blog.coolaj86.org" , "path": "" } + , "to": { "hostname": "coolaj86.com", "path": "" } + } + , { "from": { "hostname": "coolaj86.info" , "path": "" } + , "to": { "hostname": "coolaj86.com", "path": "" } + } + , { "from": { "hostname": "blog.coolaj86.info" , "path": "" } + , "to": { "hostname": "coolaj86.com", "path": "" } + } + , { "from": { "hostname": "blog.coolaj86.com" , "path": "" } + , "to": { "hostname": "coolaj86.com", "path": "" } + } + , { "from": { "hostname": "example.org" , "path": "/blog" } + , "to": { "hostname": "blog.example.com", "path": "" } + } + ].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) { + // TODO if '*' === hostname[0], omit '^' + host = host.replace( + new RegExp('^' + escapeRe(redirect.from.hostname)) + , redirect.to.hostname + ); + url = url.replace( + new RegExp('^' + escapeRe(redirect.from.path)) + , redirect.to.path + ); + }); + + return [host, url]; +} + +[ + [ "blog.coolaj86.info", "/articles/awesome.html" ] +, [ "example.org", "/blog" ] +].forEach(function (pair) { + var host = pair[0]; + var url = pair[1]; + + console.log(host, url); + console.log(redirect(host, url)); +}); diff --git a/snippets/test-upnp-igd-search.js b/snippets/test-upnp-igd-search.js new file mode 100644 index 0000000..6beba4f --- /dev/null +++ b/snippets/test-upnp-igd-search.js @@ -0,0 +1,52 @@ +'use strict'; + +var dgram = require('dgram') + , fs = require('fs') + , ssdpAddress = '239.255.255.250' + , ssdpPort = 1900 + , sourceIface = '0.0.0.0' // or ip (i.e. '192.168.1.101', '10.0.1.2') + , sourcePort = 0 // chosen at random + , searchTarget = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' + , socket + ; + +function broadcastSsdp() { + var query + ; + + // described at bit.ly/1zjVJVW + query = new Buffer( + 'M-SEARCH * HTTP/1.1\r\n' + + 'HOST: ' + ssdpAddress + ':' + ssdpPort + '\r\n' + + 'MAN: "ssdp:discover"\r\n' + + 'MX: 1\r\n' + + 'ST: ' + searchTarget + '\r\n' + + '\r\n' + ); + + // Send query on each socket + socket.send(query, 0, query.length, ssdpPort, ssdpAddress); +} + +function createSocket() { + socket = dgram.createSocket('udp4'); + + socket.on('listening', function () { + console.log('socket ready...'); + + broadcastSsdp(); + }); + + socket.on('message', function (chunk, info) { + var message = chunk.toString(); + + console.log('[incoming] UDP message'); + console.log(info); + console.log(message); + }); + + console.log('binding to', sourceIface + ':' + sourcePort); + socket.bind(sourcePort, sourceIface); +} + +createSocket(); diff --git a/vhost-sni-server.js b/vhost-sni-server.js deleted file mode 100644 index 896dea8..0000000 --- a/vhost-sni-server.js +++ /dev/null @@ -1,277 +0,0 @@ -'use strict'; - -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 -var app = connect(); - - // SSL Server -var secureContexts = {}; -var secureOpts; -var secureServer; -var securePort = process.argv[2] || 443; - - // force SSL upgrade server -var insecureServer; -var insecurePort = process.argv[3] || 80; - - // the ssl domains I have - // TODO read vhosts minus -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 getDummyAppContext(err, msg) { - if (err) { - console.error(err); - } - return connect().use(function (req, res) { - res.end('{ "error": { "message": "' + msg.replace(/"/g, '\\"') + '" } }'); - }); -} -function getAppContext(domaininfo) { - var localApp; - - try { - 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) { - return getDummyAppContext(null, "[ERROR] no app was returned by app.js for " + domaininfo.driname); - } - } - if (!localApp.then) { - localApp = PromiseA.resolve(localApp); - } else { - return localApp.catch(function (e) { - return getDummyAppContext(e, "[ERROR] initialization failed during create() for " + domaininfo.dirname); - }); - } - } catch(e) { - localApp = getDummyAppContext(e, "[ERROR] could not load app.js for " + domaininfo.dirname); - localApp = PromiseA.resolve(localApp); - - return localApp; - } - - return localApp; -} - -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(); - -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) { - res.statusCode = 404; - res.end("

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

"); - }); - */ - 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 getAppContext(domaininfo).then(function (localApp) { - // Note: pathname should NEVER have a leading '/' on its own - // we always add it explicitly - try { - domainMergeMap[domaininfo.hostname].apps.use('/' + domaininfo.pathname, localApp); - console.info('Loaded ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname); - } catch(e) { - console.error('[ERROR] ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname); - console.error(e); - } - }); - }).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] || secureContexts.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); - }); - - // 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 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 6189829..a4b6d2d 100644 --- a/walnut.js +++ b/walnut.js @@ -1,35 +1,56 @@ -var holepunch = require('./holepunch/beacon'); -var config = require('./device.json') -var ports ; +//var holepunch = require('./holepunch/beacon'); +//var config = require('./device.json'); +var securePort = process.argv[2] || 443; +var insecurePort = process.argv[3] || 80; +var redirects = require('./redirects.json'); +var path = require('path'); -ports = [ - { private: 22 - , public: 65022 - , protocol: 'tcp' - , ttl: 0 - , test: { service: 'ssh' } - , testable: false - } -, { private: 65443 - , public: 65443 - , protocol: 'tcp' - , ttl: 0 - , test: { service: 'https' } - } -, { private: 65080 - , public: 65080 - , protocol: 'tcp' - , ttl: 0 - , test: { service: 'http' } - } -]; + // force SSL upgrade server +var certsPath = path.join(__dirname, 'certs'); +// require('ssl-root-cas').inject(); +var vhostsdir = path.join(__dirname, 'vhosts'); -holepunch.run([ - 'aj.daplie.com' -, 'coolaj86.com' -, 'prod.coolaj86.com' -, 'production.coolaj86.com' -], ports).then(function () { - // TODO use as module - require('./vhost-sni-server.js'); +require('./lib/insecure-server').create(securePort, insecurePort, redirects); +require('./lib/vhost-sni-server.js').create(securePort, certsPath, vhostsdir).then(function () { + var ports ; + + ports = [ + { private: 22 + , public: 22 + , protocol: 'tcp' + , ttl: 0 + , test: { service: 'ssh' } + , testable: false + } + , { private: 443 + , public: 443 + , protocol: 'tcp' + , ttl: 0 + , test: { service: 'https' } + } + , { private: 80 + , public: 80 + , protocol: 'tcp' + , ttl: 0 + , test: { service: 'http' } + } + ]; + + /* + // TODO return a middleware + holepunch.run(require('./redirects.json').reduce(function (all, redirect) { + if (!all[redirect.from.hostname]) { + all[redirect.from.hostname] = true; + all.push(redirect.from.hostname) + } + if (!all[redirect.to.hostname]) { + all[redirect.to.hostname] = true; + all.push(redirect.to.hostname) + } + + return all; + }, []), ports).catch(function () { + console.error("Couldn't phone home. Oh well"); + }); + */ });