diff --git a/README.md b/README.md index 1ab4008..fd66ae9 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,25 @@ Serving /Users/foo/ at https://localhost.daplie.com:8443 Usage ----- +Examples: + +``` +node serve.js --servername jane.daplie.me --agree-tos --email jane@example.com --tunnel +``` + +Options: + * `-p ` - i.e. `sudo serve-https -p 443` (defaults to 80+443 or 8443) * `-d ` - i.e. `serve-https -d /tmp/` (defaults to `pwd`) * `-c ` - i.e. `server-https -c 'Hello, World! '` (defaults to directory index) -* `--express-app` - path to a file the exports an express-style app (`function (req, res, next) { ... }`) +* `--express-app ` - path to a file the exports an express-style app (`function (req, res, next) { ... }`) * `--livereload` - inject livereload into all html pages (see also: [fswatch](http://stackoverflow.com/a/13807906/151312)), but be careful if `` has thousands of files it will spike your CPU usage to 100% +* `--email ` - email to use for Let's Encrypt, Daplie DNS, Daplie Tunnel +* `--agree-tos` - agree to terms for Let's Encrypt, Daplie DNS +* `--servername ` - use `` instead of `localhost.daplie.com` +* `--tunnel` - make world-visible (must use `--servername`) + Specifying a custom HTTPS certificate: * `--key /path/to/privkey.pem` specifies the server private key diff --git a/app.js b/lib/app.js similarity index 100% rename from app.js rename to lib/app.js diff --git a/lib/ddns.js b/lib/ddns.js new file mode 100644 index 0000000..f860926 --- /dev/null +++ b/lib/ddns.js @@ -0,0 +1,88 @@ +'use strict'; + +module.exports.create = function (opts/*, servers*/) { + var PromiseA = opts.PromiseA; + var dns = PromiseA.promisifyAll(require('dns')); + + return PromiseA.all([ + dns.resolve4Async(opts.servername).then(function (results) { + return results; + }, function () {}) + , dns.resolve6Async(opts.servername).then(function (results) { + return results; + }, function () {}) + ]).then(function (results) { + var ipv4 = results[0] || []; + var ipv6 = results[1] || []; + var record; + + opts.dnsRecords = { + A: ipv4 + , AAAA: ipv6 + }; + + Object.keys(opts.ifaces).some(function (ifacename) { + var iface = opts.ifaces[ifacename]; + + return iface.ipv4.some(function (localIp) { + return ipv4.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }) || iface.ipv6.some(function (localIp) { + return ipv6.forEach(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + }); + + if (!record) { + console.info("DNS Record '" + ipv4.concat(ipv6).join(',') + "' does not match any local IP address."); + console.info("Use --ddns to allow the people of the Internet to access your server."); + } + + opts.externalIps.ipv4.some(function (localIp) { + return ipv4.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + + opts.externalIps.ipv6.some(function (localIp) { + return ipv6.some(function (remoteIp) { + if (localIp.address === remoteIp) { + record = localIp; + return record; + } + }); + }); + + if (!record) { + console.info("DNS Record '" + ipv4.concat(ipv6).join(',') + "' does not match any local IP address."); + console.info("Use --ddns to allow the people of the Internet to access your server."); + } + }); +}; + +if (require.main === module) { + var opts = { + servername: 'aj.daplie.me' + , PromiseA: require('bluebird') + }; + // ifaces + opts.ifaces = require('./local-ip.js').find(); + console.log('opts.ifaces'); + console.log(opts.ifaces); + require('./match-ips.js').match(opts.servername, opts).then(function (ips) { + opts.matchingIps = ips.matchingIps || []; + opts.externalIps = ips.externalIps; + module.exports.create(opts); + }); +} diff --git a/local-ip.js b/lib/local-ip.js similarity index 100% rename from local-ip.js rename to lib/local-ip.js diff --git a/lib/match-ips.js b/lib/match-ips.js new file mode 100644 index 0000000..dbb3ff1 --- /dev/null +++ b/lib/match-ips.js @@ -0,0 +1,117 @@ +'use strict'; + +var PromiseA = require('bluebird'); + +module.exports.match = function (servername, opts) { + return PromiseA.promisify(require('ipify'))().then(function (externalIp) { + var dns = PromiseA.promisifyAll(require('dns')); + + opts.externalIps = [ { address: externalIp, family: 'IPv4' } ]; + opts.ifaces = require('./local-ip.js').find({ externals: opts.externalIps }); + opts.externalIfaces = Object.keys(opts.ifaces).reduce(function (all, iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + iface.ipv6.forEach(function (addr) { + if (addr.external) { + addr.iface = iname; + all.push(addr); + } + }); + + return all; + }, []).filter(Boolean); + + function resolveIps(hostname) { + var allIps = []; + + return PromiseA.all([ + dns.resolve4Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv4' + }); + }); + }, function () {}) + , dns.resolve6Async(hostname).then(function (records) { + records.forEach(function (ip) { + allIps.push({ + address: ip + , family: 'IPv6' + }); + }); + }, function () {}) + ]).then(function () { + return allIps; + }); + } + + function resolveIpsAndCnames(hostname) { + return PromiseA.all([ + resolveIps(hostname) + , dns.resolveCnameAsync(hostname).then(function (records) { + return PromiseA.all(records.map(function (hostname) { + return resolveIps(hostname); + })).then(function (allIps) { + return allIps.reduce(function (all, ips) { + return all.concat(ips); + }, []); + }); + }, function () { + return []; + }) + ]).then(function (ips) { + return ips.reduce(function (all, set) { + return all.concat(set); + }, []); + }); + } + + return resolveIpsAndCnames(servername).then(function (allIps) { + var matchingIps = []; + + if (!allIps.length) { + console.warn("Could not resolve '" + servername + "'"); + } + + // { address, family } + allIps.some(function (ip) { + function match(addr) { + if (ip.address === addr.address) { + matchingIps.push(addr); + } + } + + opts.externalIps.forEach(match); + // opts.externalIfaces.forEach(match); + + Object.keys(opts.ifaces).forEach(function (iname) { + var iface = opts.ifaces[iname]; + + iface.ipv4.forEach(match); + iface.ipv6.forEach(match); + }); + + return matchingIps.length; + }); + + matchingIps.externalIps = { + ipv4: [ + { address: externalIp + , family: 'IPv4' + } + ] + , ipv6: [ + ] + }; + matchingIps.matchingIps = matchingIps; + return matchingIps; + }); + }); +}; diff --git a/lib/tunnel.js b/lib/tunnel.js new file mode 100644 index 0000000..353e701 --- /dev/null +++ b/lib/tunnel.js @@ -0,0 +1,137 @@ +'use strict'; + +module.exports.create = function (opts, servers) { + // servers = { plainserver, server } + var Oauth3 = require('oauth3-cli'); + var Tunnel = require('daplie-tunnel').create({ + Oauth3: Oauth3 + , PromiseA: opts.PromiseA + , CLI: { + init: function (/*rs, ws, state, options*/) { + // noop + } + } + }).Tunnel; + var stunnel = require('stunnel'); + var killcount = 0; + + /* + var Dup = { + write: function (chunk, encoding, cb) { + this.__my_socket.push(chunk, encoding); + cb(); + } + , read: function (size) { + var x = this.__my_socket.read(size); + if (x) { this.push(x); } + } + , setTimeout: function () { + console.log('TODO implement setTimeout on Duplex'); + } + }; + + var httpServer = require('http').createServer(function (req, res) { + console.log('req.socket.encrypted', req.socket.encrypted); + res.end('Hello, tunneled World!'); + }); + + var tlsServer = require('tls').createServer(opts.httpsOptions, function (tlsSocket) { + console.log('tls connection'); + // things get a little messed up here + httpServer.emit('connection', tlsSocket); + + // try again + //servers.server.emit('connection', tlsSocket); + }); + */ + + process.on('SIGINT', function () { + killcount += 1; + console.log('[quit] closing http and https servers'); + if (killcount >= 3) { + process.exit(1); + } + if (servers.server) { + servers.server.close(); + } + if (servers.insecureServer) { + servers.insecureServer.close(); + } + }); + + return Tunnel.token({ + refreshToken: opts.refreshToken + , email: opts.email + , domains: [ opts.servername ] + , device: { hostname: opts.devicename || opts.device } + }).then(function (result) { + // { jwt, tunnelUrl } + return stunnel.connect({ + token: result.jwt + , stunneld: result.tunnelUrl + // XXX TODO BUG // this is just for testing + , insecure: /*opts.insecure*/ true + , locals: [ + { protocol: 'https' + , hostname: opts.servername + , port: opts.port + } + , { protocol: 'http' + , hostname: opts.servername + , port: opts.insecurePort || opts.port + } + ] + // a simple passthru is proving to not be so simple + , net: require('net') /* + { + createConnection: function (info, cb) { + // data is the hello packet / first chunk + // info = { data, servername, port, host, remoteAddress: { family, address, port } } + + var myDuplex = new (require('stream').Duplex)(); + var myDuplex2 = new (require('stream').Duplex)(); + // duplex = { write, push, end, events: [ 'readable', 'data', 'error', 'end' ] }; + + myDuplex2.__my_socket = myDuplex; + myDuplex.__my_socket = myDuplex2; + + myDuplex2._write = Dup.write; + myDuplex2._read = Dup.read; + + myDuplex._write = Dup.write; + myDuplex._read = Dup.read; + + myDuplex.remoteFamily = info.remoteFamily; + myDuplex.remoteAddress = info.remoteAddress; + myDuplex.remotePort = info.remotePort; + + // socket.local{Family,Address,Port} + myDuplex.localFamily = 'IPv4'; + myDuplex.localAddress = '127.0.01'; + myDuplex.localPort = info.port; + + myDuplex.setTimeout = Dup.setTimeout; + + // this doesn't seem to work so well + //servers.server.emit('connection', myDuplex); + + // try a little more manual wrapping / unwrapping + var firstByte = info.data[0]; + if (firstByte < 32 || firstByte >= 127) { + tlsServer.emit('connection', myDuplex); + } + else { + httpServer.emit('connection', myDuplex); + } + + if (cb) { + process.nextTick(cb); + } + + return myDuplex2; + } + } + //*/ + }); + }); +}; diff --git a/package.json b/package.json index 0d1dcc4..695eb1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serve-https", - "version": "1.6.1", + "version": "2.0.2", "description": "Serves HTTPS using TLS (SSL) certs for localhost.daplie.com - great for testing and development.", "main": "serve.js", "scripts": { @@ -38,14 +38,24 @@ }, "homepage": "https://github.com/Daplie/serve-https#readme", "dependencies": { + "bluebird": "^3.4.6", + "daplie-tunnel": "git+https://github.com/Daplie/daplie-cli-tunnel.git#master", + "ddns-cli": "git+https://github.com/Daplie/node-ddns-client.git#master", "finalhandler": "^0.4.0", "httpolyglot": "^0.1.1", "ipify": "^1.1.0", - "livereload": "^0.5.0", + "le-challenge-ddns": "git+https://github.com/Daplie/le-challenge-ddns.git#master", + "le-challenge-fs": "git+https://github.com/Daplie/le-challenge-fs.git#master", + "le-challenge-sni": "^2.0.1", + "letsencrypt-express": "git+https://github.com/Daplie/letsencrypt-express.git#master", + "letsencrypt": "git+https://github.com/Daplie/node-letsencrypt.git#master", + "livereload": "^0.6.0", "localhost.daplie.com-certificates": "^1.2.0", "minimist": "^1.1.1", + "oauth3-cli": "git+https://github.com/OAuth3/oauth3-cli.git#master", "redirect-https": "^1.1.0", "serve-index": "^1.7.0", - "serve-static": "^1.10.0" + "serve-static": "^1.10.0", + "stunnel": "git+https://github.com/Daplie/node-tunnel-client.git#master" } } diff --git a/serve.js b/serve.js index 9404305..236af8d 100755 --- a/serve.js +++ b/serve.js @@ -8,6 +8,7 @@ var https = require('httpolyglot'); var http = require('http'); var fs = require('fs'); var path = require('path'); +var DDNS = require('ddns-cli'); var httpPort = 80; var httpsPort = 443; var lrPort = 35729; @@ -56,9 +57,42 @@ function createInsecureServer(port, pubdir, opts) { } function createServer(port, pubdir, content, opts) { + function approveDomains(params, certs, cb) { + // This is where you check your database and associated + // email addresses with domains and agreements and such + var domains = params.domains; + //var p; + console.log('approveDomains'); + console.log(domains); + + + // The domains being approved for the first time are listed in opts.domains + // Certs being renewed are listed in certs.altnames + if (certs) { + params.domains = certs.altnames; + //p = PromiseA.resolve(); + } + else { + //params.email = opts.email; + if (!opts.agreeTos) { + console.error("You have not previously registered '" + domains + "' so you must specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); + process.exit(1); + return; + } + params.agreeTos = opts.agreeTos; + } + + // ddns.token(params.email, domains[0]) + params.email = opts.email; + params.refreshToken = opts.refreshToken; + params.challengeType = 'dns-01'; + params.cli = opts.argv; + + cb(null, { options: params, certs: certs }); + } + return new PromiseA(function (realResolve) { - var server = https.createServer(opts.httpsOptions); - var app = require('./app'); + var app = require('./lib/app.js'); var directive = { public: pubdir, content: content, livereload: opts.livereload , servername: opts.servername, expressApp: opts.expressApp }; @@ -71,6 +105,48 @@ function createServer(port, pubdir, content, opts) { }); } + // returns an instance of node-letsencrypt with additional helper methods + var webrootPath = require('os').tmpdir(); + var leChallengeFs = require('le-challenge-fs').create({ webrootPath: webrootPath }); + //var leChallengeSni = require('le-challenge-sni').create({ webrootPath: webrootPath }); + var leChallengeDdns = require('le-challenge-ddns').create({ ttl: 1 }); + var lex = require('letsencrypt-express').create({ + // set to https://acme-v01.api.letsencrypt.org/directory in production + server: opts.debug ? 'staging' : 'https://acme-v01.api.letsencrypt.org/directory' + + // If you wish to replace the default plugins, you may do so here + // + , challenges: { + 'http-01': leChallengeFs + , 'tls-sni-01': leChallengeFs // leChallengeSni + , 'dns-01': leChallengeDdns + } + , challengeType: (opts.tunnel ? 'http-01' : 'dns-01') + , store: require('le-store-certbot').create({ webrootPath: webrootPath }) + , webrootPath: webrootPath + + // You probably wouldn't need to replace the default sni handler + // See https://github.com/Daplie/le-sni-auto if you think you do + //, sni: require('le-sni-auto').create({}) + + , approveDomains: approveDomains + }); + var secureContext; + opts.httpsOptions.SNICallback = function (servername, cb ) { + console.log('[https] servername', servername); + + if ('localhost.daplie.com' === servername) { + if (!secureContext) { + secureContext = tls.createSecureContext(opts.httpsOptions); + } + cb(null, secureContext); + return; + } + + lex.httpsOptions.SNICallback(servername, cb); + }; + var server = https.createServer(opts.httpsOptions); + server.on('error', function (err) { if (opts.errorPort || opts.manualPort) { showError(err, port); @@ -93,7 +169,7 @@ function createServer(port, pubdir, content, opts) { var server2 = livereload.createServer({ https: opts.httpsOptions , port: opts.lrPort - , exclusions: [ '.hg', '.git', '.svn', 'node_modules' ] + , exclusions: [ 'node_modules' ] }); console.info("[livereload] watching " + pubdir); @@ -119,7 +195,8 @@ function createServer(port, pubdir, content, opts) { } server.on('request', function (req, res) { - if (!req.socket.encrypted) { + console.log('[' + req.method + '] ' + req.url); + if (!req.socket.encrypted && !/\/\.well-known\/acme-challenge\//.test(req.url)) { opts.redirectApp(req, res); return; } @@ -165,6 +242,7 @@ function run() { var opts = { agreeTos: argv.agreeTos || argv['agree-tos'] , debug: argv.debug + , device: argv.device , email: argv.email , httpsOptions: { key: httpsOptions.key @@ -174,7 +252,9 @@ function run() { , argv: argv }; var peerCa; + var p; + opts.PromiseA = PromiseA; opts.httpsOptions.SNICallback = function (servername, cb) { if (!secureContext) { secureContext = tls.createSecureContext(opts.httpsOptions); @@ -244,6 +324,9 @@ function run() { if (argv.p || argv.port || argv._[0]) { opts.manualPort = true; } + if (argv.t || argv.tunnel) { + opts.tunnel = true; + } if (argv.i || argv['insecure-port']) { opts.manualInsecurePort = true; } @@ -257,13 +340,37 @@ function run() { opts.expressApp = require(path.resolve(process.cwd(), argv['express-app'])); } + if (opts.email || opts.servername) { + if (!opts.agreeTos) { + console.warn("You may need to specify --agree-tos to agree to both the Let's Encrypt and Daplie DNS terms of service."); + } + if (!opts.email) { + // TODO store email in .ddnsrc.json + console.warn("You may need to specify --email to register with both the Let's Encrypt and Daplie DNS."); + } + p = DDNS.refreshToken({ + email: opts.email + , silent: true + }, { + debug: false + , email: opts.argv.email + }).then(function (refreshToken) { + opts.refreshToken = refreshToken; + }); + } + else { + p = PromiseA.resolve(); + } + + return p.then(function () { + // can be changed to tunnel external port opts.redirectOptions = { port: opts.port }; opts.redirectApp = require('redirect-https')(opts.redirectOptions); - return createServer(port, pubdir, content, opts).then(function () { + return createServer(port, pubdir, content, opts).then(function (servers) { var msg; var p; var httpsUrl; @@ -292,12 +399,12 @@ function run() { if (!(argv.servername && defaultServername !== argv.servername && !(argv.key && argv.cert))) { // ifaces - opts.ifaces = require('./local-ip.js').find(); + opts.ifaces = require('./lib/local-ip.js').find(); promise = PromiseA.resolve(); } else { console.info("Attempting to resolve external connection for '" + argv.servername + "'"); try { - promise = require('./match-ips.js').match(argv.servername, opts); + promise = require('./lib/match-ips.js').match(argv.servername, opts); } catch(e) { console.warn("Upgrade to version 2.x to use automatic certificate issuance for '" + argv.servername + "'"); promise = PromiseA.resolve(); @@ -333,6 +440,17 @@ function run() { } }); } + else if (!opts.tunnel) { + console.info("External IP address does not match local IP address."); + console.info("Use --tunnel to allow the people of the Internet to access your server."); + } + + if (opts.tunnel) { + require('./lib/tunnel.js').create(opts, servers); + } + else if (opts.ddns) { + require('./lib/ddns.js').create(opts, servers); + } Object.keys(opts.ifaces).forEach(function (iname) { var iface = opts.ifaces[iname]; @@ -360,6 +478,7 @@ function run() { console.info(''); }); }); + }); } if (require.main === module) {