commit 56b5337d8f814cef90c424890a81e84ac6687b18 Author: AJ ONeal Date: Thu Feb 12 09:40:37 2015 +0000 initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0437d87 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +Commercial, Proprietary diff --git a/app.js b/app.js new file mode 100644 index 0000000..ac297ea --- /dev/null +++ b/app.js @@ -0,0 +1,13 @@ +'use strict'; + +var express = require('express') + ; + +module.exports.create = function (server, host, port, publicDir) { + var app = express() + ; + + app.use(express.static(publicDir)); + + return app; +}; diff --git a/bin/walnut b/bin/walnut new file mode 100755 index 0000000..3aaede2 --- /dev/null +++ b/bin/walnut @@ -0,0 +1,4 @@ +#!/usr/bin/env node +'use strict'; + +require('../walnut.js'); diff --git a/bin/walnut.js b/bin/walnut.js new file mode 120000 index 0000000..f26d3ff --- /dev/null +++ b/bin/walnut.js @@ -0,0 +1 @@ +walnut \ No newline at end of file diff --git a/etc/init.d/install.sh b/etc/init.d/install.sh new file mode 100755 index 0000000..2e5ddd6 --- /dev/null +++ b/etc/init.d/install.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sudo rsync -v walnut /etc/init.d/ +sudo chmod 755 /etc/init.d/walnut +sudo update-rc.d walnut defaults diff --git a/etc/init.d/walnut b/etc/init.d/walnut new file mode 100755 index 0000000..009252a --- /dev/null +++ b/etc/init.d/walnut @@ -0,0 +1,39 @@ +### BEGIN INIT INFO +# Provides: walnut +# Required-Start: $all +# Required-Stop: +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: WALNUT Home Cloud +### END INIT INFO + +export PATH=$PATH:/bin:/usr/bin:/usr/local/bin + +PIDFILE=/var/run/walnut.pid +DATE=`date '+%F_%H-%M-%S'` + +cd /srv/walnut + +case "$1" in + start) + mkdir -p /srv/walnut/logs + mkdir -p /srv/walnut/.forever + exec forever -p /srv/walnut/.forever --minUptime=20000 --spinSleepTime=100 --workingDir=/srv/walnut/ -l "/srv/walnut/logs/access.${DATE}.log" -e "/srv/walnut/logs/error.${DATE}.log" --pidFile=$PIDFILE start /srv/walnut/bin/walnut.js + ;; + stop) + exec forever stopall + ;; + restart) + mkdir -p /srv/walnut/logs + mkdir -p /srv/walnut/.forever + exec forever stopall + exec forever -p /srv/walnut/.forever --minUptime=20000 --spinSleepTime=100 --workingDir=/srv/walnut/ -l "/srv/walnut/logs/access.${DATE}.log" -e "/srv/walnut/logs/error.${DATE}.log" --pidFile=$PIDFILE start /srv/walnut/bin/walnut.js + ;; + *) + + echo "Usage: /etc/init.d/walnut {start|stop}" + exit 1 + ;; +esac + +exit 0 diff --git a/etc/init/walnut.conf b/etc/init/walnut.conf new file mode 100644 index 0000000..d7c1cc5 --- /dev/null +++ b/etc/init/walnut.conf @@ -0,0 +1,21 @@ +description "WALNUT, by Daplie" +version "0.1" +author "Daplie Inc" + +# Upstart has nothing in $PATH by default +env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Keep the server running on crash or machine reboot +respawn +respawn limit 10 120 +start on runlevel [2345] + +# Start the server using spark and redirect output to log files +script + DATE=`date '+%F_%H-%M-%S'` + cd /srv/walnut + mkdir -p logs + exec node bin/walnut \ + > "./logs/access.${DATE}.log" \ + 2> "./logs/error.${DATE}.log" +end script diff --git a/holepunch/beacon.js b/holepunch/beacon.js new file mode 100644 index 0000000..b894aff --- /dev/null +++ b/holepunch/beacon.js @@ -0,0 +1,84 @@ +'use strict'; + +var PromiseA = require('bluebird').Promise + , updateIp = require('./helpers/update-ip.js').update + , request = PromiseA.promisifyAll(require('request')) + , requestAsync = PromiseA.promisify(require('request')) + , upnpForward = require('./helpers/upnp-forward').upnpForward + , pmpForward = require('./helpers/pmp-forward').pmpForward + , loopbackHttps = require('./loopback-https') + //, checkip = require('check-ip-address') + ; + +function openPort(ip, port) { + if (!/tcp|https|http/.test(port.protocol || 'tcp')) { + throw new Error('not yet supported \'' + port.protocol + '\''); + } + + if (false === port.testable) { + return PromiseA.resolve(); + } + + return loopbackHttps.create(ip, port.private, port.public).then(function () { + console.log('success'); + }).catch(function (err) { + // TODO test err + return upnpForward(port).catch(function (err) { + console.error('[ERROR] UPnP Port Forward'); + console.error(err); + // TODO test err + return pmpForward(port); + }).then(function () { + return loopbackHttps.create(ip, port.private, port.public); + }); + }); +} + +// 1. update dyndns +// 1.5. check ip every 5 min +// 2. loopback test on ip for http / https / ssh +// 3. if needed: discover gateway, map ports +function beacon(hostnames, ports) { + // test with + // dig -p 53 @redirect-www.org pi.nadal.daplie.com A + return updateIp({ + updater: 'redirect-www.org' + , port: 65443 + , ddns: hostnames.map(function (hostname) { + return { "name": hostname /*, "value": ipaddress, "type": "A"*/ }; + }) + }).then(function (data) { + var promises = []; + + console.log("Updated DynDNS"); + console.log(data); + + ports.forEach(function (port) { + promises.push(openPort(JSON.parse(data)[0].answers[0] || hostname, port)); + }); + + return PromiseA.all(promises); + }).then(function () { + console.log('opened ports'); + }); + +/* + request.getAsync('http://checkip.hellabit.com').spread(function (resp, data) { + console.log("External IP is", data); + }).then(function () { + return upnpForward().catch(function (err) { + console.error('ERROR: UPnP failure:'); + console.error(err); + }); + }).then(function () { + return pmpForward().catch(function (err) { + console.error('TODO: Notify user that their router is not compatible'); + }); + }); + + // TODO test roundtrip +*/ +} + +//setInterval(beacon, 5 * 60 * 1000); +exports.run = beacon; diff --git a/holepunch/helpers/pmp-forward.js b/holepunch/helpers/pmp-forward.js new file mode 100644 index 0000000..f971019 --- /dev/null +++ b/holepunch/helpers/pmp-forward.js @@ -0,0 +1,57 @@ +'use strict'; + +var PromiseA = require('bluebird').Promise + , natpmp = require('nat-pmp') + , exec = require('child_process').exec + ; + +exports.pmpForward = function (port) { + return new PromiseA(function (resolve, reject) { + exec('ip route show default', function (err, stdout, stderr) { + var gw + ; + + if (err || stderr) { reject(err || stderr); return; } + + // default via 192.168.1.1 dev eth0 + gw = stdout.replace(/^default via (\d+\.\d+\.\d+\.\d+) dev[\s\S]+/m, '$1'); + console.log('Possible PMP gateway is', gw); + + // create a "client" instance connecting to your local gateway + var client = natpmp.connect(gw); + + function setPortForward() { + // setup a new port mapping + client.portMapping({ + private: port.private || port.public + , public: port.public || port.private + , ttl: port.ttl || 0 // 600 + }, function (err, info) { + if (err) { + reject(err); + return; + } + + console.log(info); + // { + // type: 'tcp', + // epoch: 8922109, + // private: 22, + // public: 2222, + // ... + // } + resolve(); + }); + } + + // explicitly ask for the current external IP address + setTimeout(function () { + client.externalIp(function (err, info) { + if (err) throw err; + console.log('Current external IP address: %s', info.ip.join('.')); + setPortForward(); + }); + }); + }); + }); +}; diff --git a/holepunch/helpers/update-ip.js b/holepunch/helpers/update-ip.js new file mode 100644 index 0000000..de15b7a --- /dev/null +++ b/holepunch/helpers/update-ip.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +'use strict'; + +var PromiseA = require('bluebird').Promise + , https = require('https') + , fs = require('fs') + , path = require('path') + ; + +module.exports.update = function (opts) { + return new PromiseA(function (resolve, reject) { + var options + , hostname = opts.updater || 'redirect-www.org' + , port = opts.port || 65443 + ; + + options = { + host: hostname + , port: port + , method: 'POST' + , headers: { + 'Content-Type': 'application/json' + } + , path: '/api/ddns' + , auth: opts.auth || 'admin:secret' + , ca: [ fs.readFileSync(path.join(__dirname, '..', 'certs', 'ca', 'my-root-ca.crt.pem')) ] + }; + options.agent = new https.Agent(options); + + https.request(options, function(res) { + var textData = ''; + + res.on('error', function (err) { + reject(err); + }); + res.on('data', function (chunk) { + textData += chunk.toString(); + // console.log(chunk.toString()); + }); + res.on('end', function () { + resolve(textData); + }); + }).end(JSON.stringify(opts.ddns, null, ' ')); + }); +}; diff --git a/holepunch/helpers/upnp-forward.js b/holepunch/helpers/upnp-forward.js new file mode 100644 index 0000000..3bd2224 --- /dev/null +++ b/holepunch/helpers/upnp-forward.js @@ -0,0 +1,60 @@ +'use strict'; + +var PromiseA = require('bluebird').Promise; +var natUpnp = require('nat-upnp'); + +exports.upnpForward = function (port) { + return natUpnp.createClient({ timeout: 1800 }).then(function (client) { + return client.portMapping({ + public: port.public, + private: port.private || port.public, + ttl: port.ttl || 0 + })/*.then(function () { + var promitter = client.getMappings(); + + promitter.on('entry', function (entry, i) { + console.log('entry', i); + console.log(entry); + }).then(function (mappings) { + console.log('mappings'); + console.log(mappings); + }); + + return promitter; + })*/; + }); +}; + +/* +client.portUnmapping({ + public: 80 +}); + +.findGateway().then(function (stuff) { + console.log('[a] gateway'); + console.log(stuff.gateway); + console.log('[a] address'); + console.log(stuff.address); + }).then(function () { + return client +*/ + +/* +client.getMappings({ local: true }, function(err, results) { + console.log('local mappings', results); +}); + +client.externalIp(function(err, ip) { + console.log('ext-ip', ip); +}); +*/ + +if (require.main === module) { + exports.upnpForward({ public: 65080, private: 65080, ttl: 0 }).then(function () { + console.log('done'); + }).catch(function (err) { + console.error('ERROR'); + console.error(err); + throw err; + }); +} diff --git a/holepunch/loopback-https.js b/holepunch/loopback-https.js new file mode 100644 index 0000000..b5a098f --- /dev/null +++ b/holepunch/loopback-https.js @@ -0,0 +1,124 @@ +'use strict'; + +var https = require('https'); +var path = require('path'); +var fs = require('fs'); +var PromiseA = global.Promise || require('bluebird').Promise; + +exports.create = function (ip, localPort, externalPort) { + return new PromiseA(function (resolve, reject) { + var token = Math.random().toString(16).split('.')[1]; + var tokenPath = Math.random().toString(16).split('.')[1]; + var options; + var server; + var options; + var certsPath = path.join(__dirname, 'certs', 'server'); + var caCertsPath = path.join(__dirname, 'certs', 'ca'); + + + function testConnection() { + var awesome = false; + var timetok; + var webreq; + var options = { + // not hostname because we set headers.host on our own + host: ip + , headers: { + // whatever's on the fake cert + 'Host': 'redirect-www.org' + } + , port: externalPort + , path: '/' + tokenPath + , ca: fs.readFileSync(path.join(caCertsPath, 'my-root-ca.crt.pem')) + }; + options.agent = new https.Agent(options); + + timetok = setTimeout(function () { + reject(new Error("timed out while testing NAT loopback for port " + externalPort)); + }, 2000); + + function finishHim(err) { + clearTimeout(timetok); + server.close(function () { + if (!err && awesome) { + resolve(); + } + }); + + if (err || !awesome) { + if (err) { + reject(err); + } + else if (!awesome) { + reject(new Error("loopback failed. Why? here's my best guess: " + + "the ssl cert matched, so you've probably got two boxes and this isn't the right one")); + } + return; + } + } + + webreq = https.request(options, function(res) { + res.on('data', function (resToken) { + if (resToken.toString() === token) { + awesome = true; + return; + } + }); + res.on('error', function (err) { + console.error('[ERROR] https.request.response'); + console.error(err); + finishHim(new Error("loopback failed. Why? here's my best guess: " + + "the connection was interrupted")); + }); + res.on('end', function () { + finishHim(); + }); + }); + + webreq.on('error', function (err) { + console.error('[ERROR] https.request'); + console.error(err); + if (/ssl|cert|chain/i.test(err.message || err.toString())) { + finishHim(new Error("loopback failed. Why? here's my best guess: " + + "the ssl cert validation may have failed (might port-forward to the wrong box)")); + } else { + finishHim(new Error("loopback failed. Why? here's my best guess: " + + "port forwarding isn't configured for " + ip + ":" + externalPort + " to " + localPort)); + } + }); + webreq.end(); + } + + // + // SSL Certificates + // + options = { + key: fs.readFileSync(path.join(certsPath, 'my-server.key.pem')) + , ca: [ fs.readFileSync(path.join(caCertsPath, 'my-root-ca.crt.pem')) ] + , cert: fs.readFileSync(path.join(certsPath, 'my-server.crt.pem')) + , requestCert: false + , rejectUnauthorized: false + }; + + // + // Serve an Express App securely with HTTPS + // + server = https.createServer(options); + function listen(app) { + server.on('request', app); + server.listen(localPort, function () { + localPort = server.address().port; + setTimeout(testConnection, 2000); + }); + } + + listen(function (req, res) { + if (('/' + tokenPath) === req.url) { + res.end(token); + return; + } + + res.end('loopback failure'); + }); + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..072b7eb --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "walnut", + "version": "0.1.0", + "description": "zero-config home cloud server", + "main": "walnut.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/Daplie/walnut.git" + }, + "keywords": [ + "ssl", + "tls", + "https", + "rsa", + "pem", + "example", + "demo", + "test", + "openssl", + "crt", + "p12", + "csr", + "certificate", + "certs", + "cert", + "key", + "private", + "public" + ], + "author": "AJ ONeal (https://daplie.com)", + "license": "Apache2", + "bugs": { + "url": "https://github.com/Daplie/walnut/issues" + }, + "homepage": "https://github.com/Daplie/walnut", + "dependencies": { + "bluebird": "^2.9.6", + "connect": "^3.3.4", + "foreachasync": "^5.0.5", + "ssl-root-cas": "^1.1.7", + "vhost": "^3.0.0", + "bluebird": "^2.9.3", + "check-ip-address": "^1.0.0", + "express": "^4.11.2", + "request": "^2.51.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..d344408 --- /dev/null +++ b/public/index.html @@ -0,0 +1,13 @@ + + +
My name is Marvin, not that you would care.
+
+You've reached {{host}} on port {{port}}. Meh... Congratulations, I guess."
+ + + diff --git a/serve.js b/serve.js new file mode 100755 index 0000000..8fd5b37 --- /dev/null +++ b/serve.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +'use strict'; + +var https = require('https') + , http = require('http') + , path = require('path') + , port = process.argv[2] || 65443 + , insecurePort = process.argv[3] || 65080 + , fs = require('fs') + , path = require('path') + , checkip = require('check-ip-address') + , server + , insecureServer + , options + , certsPath = path.join(__dirname, 'certs', 'server') + , caCertsPath = path.join(__dirname, 'certs', 'ca') + ; + + +// +// SSL Certificates +// +options = { + key: fs.readFileSync(path.join(certsPath, 'my-server.key.pem')) +, ca: [ fs.readFileSync(path.join(caCertsPath, 'my-root-ca.crt.pem')) ] +, cert: fs.readFileSync(path.join(certsPath, 'my-server.crt.pem')) +, requestCert: false +, rejectUnauthorized: false +}; + + +// +// Serve an Express App securely with HTTPS +// +server = https.createServer(options); +checkip.getExternalIp().then(function (ip) { + var host = ip || 'local.helloworld3000.com' + ; + + function listen(app) { + server.on('request', app); + server.listen(port, function () { + port = server.address().port; + console.log('Listening on https://127.0.0.1:' + port); + console.log('Listening on https://local.helloworld3000.com:' + port); + if (ip) { + console.log('Listening on https://' + ip + ':' + port); + } + }); + } + + var publicDir = path.join(__dirname, 'public'); + var app = require('./app').create(server, host, port, publicDir); + listen(app); +}); + + +// +// 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 newLocation = 'https://' + + req.headers.host.replace(/:\d+/, ':' + port) + req.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/vhost-sni-server.js b/vhost-sni-server.js new file mode 100644 index 0000000..ee6e82b --- /dev/null +++ b/vhost-sni-server.js @@ -0,0 +1,142 @@ +'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') + + // connect / express app + , app = connect() + + // SSL Server + , secureContexts = {} + , secureOpts + , secureServer + , securePort = /*process.argv[2] ||*/ 443 + + // force SSL upgrade server + , insecureServer + , insecurePort = /*process.argv[3] ||*/ 80 + + // the ssl domains I have + // TODO read vhosts minus + , domains = fs.readdirSync(path.join(__dirname, 'vhosts')).filter(function (node) { + // not a hidden or private file + return '.' !== node[0] && '_' !== node[0]; + }) + ; + +require('ssl-root-cas') + .inject() + ; + +function getAppContext(domain) { + var localApp + ; + + localApp = require(path.join(__dirname, 'vhosts', domain, 'app.js')); + if (localApp.create) { + // TODO read local config.yml and pass it in + localApp = localApp.create(/*config*/); + } + if (!localApp.then) { + localApp = PromiseA.resolve(localApp); + } + + 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; + + return getAppContext(domain).then(function (localApp) { + app.use(vhost('www.' + domain, localApp)); + app.use(vhost(domain, localApp)); + }); +}).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.

"); + }); + */ +}); + +//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); +}); + +// +// 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 newLocation = 'https://' + + req.headers.host.replace(/:\d+/, ':' + port) + req.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/vhosts/.gitkeep b/vhosts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/walnut.js b/walnut.js new file mode 100644 index 0000000..57bfe42 --- /dev/null +++ b/walnut.js @@ -0,0 +1,34 @@ +var holepunch = require('./holepunch/beacon'); +var config = require('./device.json') +var ports ; + +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' } + } +]; + +holepunch.run([ + 'aj.daplie.com' +, 'coolaj86.com' +, 'prod.coolaj86.com' +, 'production.coolaj86.com' +], ports).then(function () { + require('./vhost-sni-server.js'); +});