diff --git a/lib/goldilocks.js b/lib/goldilocks.js index 78905a2..d398741 100644 --- a/lib/goldilocks.js +++ b/lib/goldilocks.js @@ -5,132 +5,22 @@ module.exports.create = function (deps, config) { //var PromiseA = global.Promise; var PromiseA = require('bluebird'); - var greenlock = require('greenlock'); var listeners = require('./servers').listeners; - var parseSni = require('sni'); var modules = { }; - var program = { - tlsOptions: require('localhost.daplie.me-certificates').merge({}) -// , acmeDirectoryUrl: 'https://acme-v01.api.letsencrypt.org/directory' - , acmeDirectoryUrl: 'https://acme-staging.api.letsencrypt.org/directory' -// , challengeType: 'tls-sni-01' // won't work with a tunnel - , challengeType: 'http-01' - }; - var secureContexts = {}; - var tunnelAdminTlsOpts = {}; - var tls = require('tls'); - var domainMatches = require('./match-domain').match; - - var tlsRouter = { - proxy: function (socket, opts, mod) { - var newConn = deps.net.createConnection({ - port: mod.port - , host: mod.address || '127.0.0.1' - - , servername: opts.servername - , data: opts.data - , remoteFamily: opts.family || socket.remoteFamily || socket._remoteFamily || socket._handle._parent.owner.stream.remoteFamily - , remoteAddress: opts.address || socket.remoteAddress || socket._remoteAddress || socket._handle._parent.owner.stream.remoteAddress - , remotePort: opts.port || socket.remotePort || socket._remotePort || socket._handle._parent.owner.stream.remotePort - }, function () { - // this will happen before 'data' is triggered - }); - - newConn.pipe(socket); - socket.pipe(newConn); - } - , terminate: function (socket) { - // We terminate the TLS by emitting the connections of the TLS server and it should handle - // everything we need to do for us. - program.tlsTunnelServer.emit('connection', socket); - } - - , handleModules: function (socket, opts) { - // needs to wind up in one of 2 states: - // 1. SNI-based Proxy / Tunnel (we don't even need to put it through the tlsSocket) - // 2. Terminated (goes on to a particular module or route, including the admin interface) - - var handled = (config.tls.modules || []).some(function (mod) { - var relevant = mod.domains.some(function (pattern) { - return domainMatches(pattern, opts.servername); - }); - if (!relevant) { - return false; - } - - if (mod.name === 'proxy') { - tlsRouter.proxy(socket, opts, mod); - } - else if (mod.name === 'terminate') { - tlsRouter.terminate(socket); - } - else { - console.error('saw unknown TLS module', mod); - return false; - } - - return true; - }); - - // We gotta do something, so when in doubt terminate the TLS since we don't really have - // any good place to default to when proxying. - if (!handled) { - tlsRouter.terminate(socket); - } - } - - , processSocket: function (socket, firstChunk, opts) { - if (opts.hyperPeek) { - // See "PEEK COMMENT" for more info - // This was already peeked at by the tunneler and this connection has been created - // in a way that should work with node's TLS server, so we don't need to do any - // of the myDuplex stuff that we need to do with non-tunnel connections. - tlsRouter.handleModules(socket, opts); - return; - } - - // Why all this wacky-do with the myDuplex? - // because https://github.com/nodejs/node/issues/8854, that's why - // (because node's internal networking layer == 💩 sometimes) - var myDuplex = require('tunnel-packer').Stream.create(socket); - myDuplex.remoteAddress = opts.remoteAddress || myDuplex.remoteAddress; - myDuplex.remotePort = opts.remotePort || myDuplex.remotePort; - - socket.on('data', function (chunk) { - console.log('[' + Date.now() + '] tls socket data', chunk.byteLength); - myDuplex.push(chunk); - }); - socket.on('error', function (err) { - console.error('[error] httpsTunnel (Admin) TODO close'); - console.error(err); - myDuplex.emit('error', err); - }); - socket.on('close', function () { - myDuplex.end(); - }); - - var address = opts.localAddress || socket.localAddress; - var port = opts.localPort || socket.localPort; - console.log('[tlsRouter] ' + address + ':' + port + ' servername', opts.servername, myDuplex.remoteAddress); - - tlsRouter.handleModules(myDuplex, opts); - process.nextTick(function () { - // this must happen after the socket is emitted to the next in the chain, - // but before any more data comes in via the network - socket.unshift(firstChunk); - }); - } - }; - // opts = { servername, encrypted, peek, data, remoteAddress, remotePort } function peek(conn, firstChunk, opts) { + opts.firstChunk = firstChunk; + conn.__opts = opts; // TODO port/service-based routing can do here // TLS byte 1 is handshake and byte 6 is client hello if (0x16 === firstChunk[0]/* && 0x01 === firstChunk[5]*/) { - opts.servername = (parseSni(firstChunk)||'').toLowerCase() || 'localhost.invalid'; - tlsRouter.processSocket(conn, firstChunk, opts); + if (!modules.tls) { + modules.tls = require('./modules/tls').create(deps, config, netHandler); + } + + modules.tls.emit('connection', conn); return; } @@ -151,7 +41,6 @@ module.exports.create = function (deps, config) { modules.http = require('./modules/http.js').create(deps, config); } - conn.__opts = opts; modules.http.emit('connection', conn); return; } @@ -207,92 +96,6 @@ module.exports.create = function (deps, config) { }; } - function approveDomains(opts, certs, cb) { - // This is where you check your database and associated - // email addresses with domains and agreements and such - - // The domains being approved for the first time are listed in opts.domains - // Certs being renewed are listed in certs.altnames - - function complete(err, stuff) { - opts.email = stuff.email; - opts.agreeTos = stuff.agreeTos; - opts.server = stuff.server; - opts.challengeType = stuff.challengeType; - - cb(null, { options: opts, certs: certs }); - } - - if (certs) { - // TODO make sure the same options are used for renewal as for registration? - opts.domains = certs.altnames; - - cb(null, { options: opts, certs: certs }); - return; - } - - // check config for domain name - if (-1 !== config.tls.servernames.indexOf(opts.domain)) { - // TODO how to handle SANs? - // TODO fetch domain-specific email - // TODO fetch domain-specific acmeDirectory - // NOTE: you can also change other options such as `challengeType` and `challenge` - // opts.challengeType = 'http-01'; - // opts.challenge = require('le-challenge-fs').create({}); // TODO this doesn't actually work yet - complete(null, { - email: config.tls.email, agreeTos: true, server: program.acmeDirectoryUrl, challengeType: program.challengeType }); - return; - } - // TODO ask http module about the default path (/srv/www/:hostname) - // (if it exists, we can allow and add to config) - if (!modules.http) { - modules.http = require('./modules/http.js').create(deps, config); - } - modules.http.checkServername(opts.domain).then(function (stuff) { - if (!stuff || !stuff.domains) { - // TODO once precheck is implemented we can just let it pass if it passes, yknow? - cb(new Error('domain is not allowed')); - return; - } - - complete(null, { - domain: stuff.domain || stuff.domains[0] - , domains: stuff.domains - , email: stuff.email || program.email - , server: stuff.acmeDirectoryUrl || program.acmeDirectoryUrl - , challengeType: stuff.challengeType || program.challengeType - , challenge: stuff.challenge - }); - return; - }, cb); - } - - function getAcme() { - return greenlock.create({ - - //server: 'staging' - server: 'https://acme-v01.api.letsencrypt.org/directory' - - , challenges: { - // TODO dns-01 - 'http-01': require('le-challenge-fs').create({ webrootPath: '/tmp/acme-challenges', debug: config.debug }) - , 'tls-sni-01': require('le-challenge-sni').create({ debug: config.debug }) - //, 'dns-01': require('le-challenge-ddns').create() - } - - , store: require('le-store-certbot').create({ webrootPath: '/tmp/acme-challenges' }) - - //, email: program.email - - //, agreeTos: program.agreeTos - - , approveDomains: approveDomains - - //, approvedDomains: program.servernames - - }); - } - deps.tunnel = deps.tunnel || {}; deps.tunnel.net = { createConnection: function (opts, cb) { @@ -371,54 +174,6 @@ module.exports.create = function (deps, config) { } }; - Object.keys(program.tlsOptions).forEach(function (key) { - tunnelAdminTlsOpts[key] = program.tlsOptions[key]; - }); - tunnelAdminTlsOpts.SNICallback = function (sni, cb) { - console.log("[tlsOptions.SNICallback] SNI: '" + sni + "'"); - - var tlsOptions; - - // Static Certs - if (/.*localhost.*\.daplie\.me/.test(sni.toLowerCase())) { - // TODO implement - if (!secureContexts[sni]) { - tlsOptions = require('localhost.daplie.me-certificates').mergeTlsOptions(sni, {}); - } - if (tlsOptions) { - secureContexts[sni] = tls.createSecureContext(tlsOptions); - } - if (secureContexts[sni]) { - console.log('Got static secure context:', sni, secureContexts[sni]); - cb(null, secureContexts[sni]); - return; - } - } - - if (!program.greenlock) { - program.greenlock = getAcme(); - } - (program.greenlock.tlsOptions||program.greenlock.httpsOptions).SNICallback(sni, cb); - }; - - program.tlsTunnelServer = tls.createServer(tunnelAdminTlsOpts, function (tlsSocket) { - console.log('(pre-terminated) tls connection, addr:', tlsSocket.remoteAddress); - // things get a little messed up here - //tlsSocket.on('data', function (chunk) { - // console.log('terminated data:', chunk.toString()); - //}); - //(program.httpTunnelServer || program.httpServer).emit('connection', tlsSocket); - //tcpRouter.get(conn.localAddress, conn.localPort)(conn, firstChunk, { encrypted: false }); - netHandler(tlsSocket, { - servername: tlsSocket.servername - , encrypted: true - // remoteAddress... ugh... https://github.com/nodejs/node/issues/8854 - , remoteAddress: tlsSocket.remoteAddress || tlsSocket._remoteAddress || tlsSocket._handle._parent.owner.stream.remoteAddress - , remotePort: tlsSocket.remotePort || tlsSocket._remotePort || tlsSocket._handle._parent.owner.stream.remotePort - , remoteFamily: tlsSocket.remoteFamily || tlsSocket._remoteFamily || tlsSocket._handle._parent.owner.stream.remoteFamily - }); - }); - var listenPromises = []; var tcpPortMap = {}; function addPorts(bindList) { diff --git a/lib/modules/tls.js b/lib/modules/tls.js new file mode 100644 index 0000000..054bea5 --- /dev/null +++ b/lib/modules/tls.js @@ -0,0 +1,235 @@ +'use strict'; + +module.exports.create = function (deps, config, netHandler) { + var tls = require('tls'); + var parseSni = require('sni'); + var greenlock = require('greenlock'); + var domainMatches = require('../match-domain').match; + + function extractSocketProp(socket, propName) { + // remoteAddress, remotePort... ugh... https://github.com/nodejs/node/issues/8854 + return socket[propName] + || socket['_' + propName] + || socket._handle._parent.owner.stream[propName] + ; + } + + var le = greenlock.create({ + // server: 'staging' + server: 'https://acme-v01.api.letsencrypt.org/directory' + + , challenges: { + 'http-01': require('le-challenge-fs').create({ webrootPath: '/tmp/acme-challenges', debug: config.debug }) + , 'tls-sni-01': require('le-challenge-sni').create({ debug: config.debug }) + // TODO dns-01 + //, 'dns-01': require('le-challenge-ddns').create() + } + + , store: require('le-store-certbot').create({ webrootPath: '/tmp/acme-challenges' }) + + , approveDomains: function (opts, certs, cb) { + // This is where you check your database and associated + // email addresses with domains and agreements and such + + // The domains being approved for the first time are listed in opts.domains + // Certs being renewed are listed in certs.altnames + if (certs) { + // TODO make sure the same options are used for renewal as for registration? + opts.domains = certs.altnames; + + cb(null, { options: opts, certs: certs }); + return; + } + + function complete(optsOverride) { + Object.keys(optsOverride).forEach(function (key) { + opts[key] = optsOverride[key]; + }); + + cb(null, { options: opts, certs: certs }); + } + + + // check config for domain name + if (-1 !== (config.tls.servernames || []).indexOf(opts.domain)) { + // TODO how to handle SANs? + // TODO fetch domain-specific email + // TODO fetch domain-specific acmeDirectory + // NOTE: you can also change other options such as `challengeType` and `challenge` + // opts.challengeType = 'http-01'; + // opts.challenge = require('le-challenge-fs').create({}); // TODO this doesn't actually work yet + complete({ + email: config.tls.email + , agreeTos: true + , server: config.tls.acmeDirectoryUrl || le.server + , challengeType: config.tls.challengeType || 'http-01' + }); + return; + } + + // TODO ask http module (and potentially all other modules) about what domains it can + // handle. We can allow any domains that other modules will handle after we terminate TLS. + cb(new Error('domain is not allowed')); + // if (!modules.http) { + // modules.http = require('./modules/http.js').create(deps, config); + // } + // modules.http.checkServername(opts.domain).then(function (stuff) { + // if (!stuff || !stuff.domains) { + // // TODO once precheck is implemented we can just let it pass if it passes, yknow? + // cb(new Error('domain is not allowed')); + // return; + // } + + // complete({ + // domain: stuff.domain || stuff.domains[0] + // , domains: stuff.domains + // , email: stuff.email || program.email + // , server: stuff.acmeDirectoryUrl || program.acmeDirectoryUrl + // , challengeType: stuff.challengeType || program.challengeType + // , challenge: stuff.challenge + // }); + // return; + // }, cb); + } + }); + le.tlsOptions = le.tlsOptions || le.httpsOptions; + + var secureContexts = {}; + var terminatorOpts = require('localhost.daplie.me-certificates').merge({}); + terminatorOpts.SNICallback = function (sni, cb) { + console.log("[tlsOptions.SNICallback] SNI: '" + sni + "'"); + + var tlsOptions; + + // Static Certs + if (/.*localhost.*\.daplie\.me/.test(sni.toLowerCase())) { + // TODO implement + if (!secureContexts[sni]) { + tlsOptions = require('localhost.daplie.me-certificates').mergeTlsOptions(sni, {}); + } + if (tlsOptions) { + secureContexts[sni] = tls.createSecureContext(tlsOptions); + } + if (secureContexts[sni]) { + console.log('Got static secure context:', sni, secureContexts[sni]); + cb(null, secureContexts[sni]); + return; + } + } + + le.tlsOptions.SNICallback(sni, cb); + }; + + var terminator = tls.createServer(terminatorOpts, function (socket) { + console.log('(pre-terminated) tls connection, addr:', socket.remoteAddress); + + netHandler(socket, { + servername: socket.servername + , encrypted: true + // remoteAddress... ugh... https://github.com/nodejs/node/issues/8854 + , remoteAddress: extractSocketProp(socket, 'remoteAddress') + , remotePort: extractSocketProp(socket, 'remotePort') + , remoteFamily: extractSocketProp(socket, 'remoteFamily') + }); + }); + + function proxy(socket, opts, mod) { + var destination = mod.address.split(':'); + + var newConn = deps.net.createConnection({ + port: destination[1] + , host: destination[0] || '127.0.0.1' + + , servername: opts.servername + , data: opts.firstChunk + , remoteFamily: opts.family || extractSocketProp(socket, 'remoteFamily') + , remoteAddress: opts.address || extractSocketProp(socket, 'remoteAddress') + , remotePort: opts.port || extractSocketProp(socket, 'remotePort') + }); + + newConn.write(opts.firstChunk); + newConn.pipe(socket); + socket.pipe(newConn); + } + + function terminate(socket, opts) { + console.log('[tls-terminate] ' + opts.localAddress || socket.localAddress + ':' + opts.localPort || socket.localPort + ' servername', opts.servername, socket.remoteAddress); + + if (opts.hyperPeek) { + // This connection was peeked at using a method that doesn't interferre with the TLS + // server's ability to handle it properly. Currently the only way this happens is + // with tunnel connections where we have the first chunk of data before creating the + // new connection (thus removing need to get data off the new connection). + terminator.emit('connection', socket); + return; + } + + // The hyperPeek flag wasn't set, so we had to read data off of this connection, which + // means we can no longer use it directly in the TLS server. + // See https://github.com/nodejs/node/issues/8752 (node's internal networking layer == 💩 sometimes) + var myDuplex = require('tunnel-packer').Stream.create(socket); + myDuplex.remoteAddress = opts.remoteAddress || myDuplex.remoteAddress; + myDuplex.remotePort = opts.remotePort || myDuplex.remotePort; + + socket.on('data', function (chunk) { + console.log('[' + Date.now() + '] tls socket data', chunk.byteLength); + myDuplex.push(chunk); + }); + socket.on('error', function (err) { + console.error('[error] httpsTunnel (Admin) TODO close'); + console.error(err); + myDuplex.emit('error', err); + }); + socket.on('close', function () { + myDuplex.end(); + }); + + terminator.emit('connection', myDuplex); + process.nextTick(function () { + // this must happen after the socket is emitted to the next in the chain, + // but before any more data comes in via the network + socket.unshift(opts.firstChunk); + }); + } + + function handleConn(socket, opts) { + opts.servername = (parseSni(opts.firstChunk)||'').toLowerCase() || 'localhost.invalid'; + // needs to wind up in one of 2 states: + // 1. SNI-based Proxy / Tunnel (we don't even need to put it through the tlsSocket) + // 2. Terminated (goes on to a particular module or route, including the admin interface) + // 3. Closed (we don't recognize the SNI servername as something we actually want to handle) + + var handled = (config.tls.modules || []).some(function (mod) { + var relevant = mod.domains.some(function (pattern) { + return domainMatches(pattern, opts.servername); + }); + if (!relevant) { + return false; + } + + if (mod.name === 'proxy') { + proxy(socket, opts, mod); + } + else { + console.error('saw unknown TLS module', mod); + return false; + } + + return true; + }); + + // TODO: figure out all of the domains that the other modules intend to handle, and only + // terminate those ones, closing connections for all others. + if (!handled) { + terminate(socket, opts); + } + } + + return { + emit: function (type, socket) { + if (type === 'connection') { + handleConn(socket, socket.__opts); + } + } + }; +};