forked from coolaj86/goldilocks.js
		
	
		
			
				
	
	
		
			473 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			473 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| module.exports.create = function (deps, config) {
 | |
|   console.log('config', 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 tcpRouter = {
 | |
|     _map: { }
 | |
|   , _create: function (address, port) {
 | |
|       // port provides hinting for http, smtp, etc
 | |
|       return function (conn, firstChunk, opts) {
 | |
|         console.log('[tcpRouter] ' + address + ':' + port + ' ' + (opts.servername || ''));
 | |
| 
 | |
|         var m;
 | |
|         var str;
 | |
|         var hostname;
 | |
|         var newHeads;
 | |
| 
 | |
|         // TODO test per-module
 | |
|         // Maybe HTTP
 | |
|         if (firstChunk[0] > 32 && firstChunk[0] < 127) {
 | |
|           str = firstChunk.toString();
 | |
|           m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im);
 | |
|           hostname = (m && m[1].toLowerCase() || '').split(':')[0];
 | |
|           console.log('[tcpRouter] hostname', hostname);
 | |
|           if (/HTTP\//i.test(str)) {
 | |
|             //conn.__service = 'http';
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         if (!hostname) {
 | |
|           // TODO allow tcp tunneling
 | |
|           // TODO we need some way of tagging tcp as either terminated tls or insecure
 | |
|           conn.write(
 | |
|             "HTTP/1.1 404 Not Found\r\n"
 | |
|           + "Date: Fri, 31 Dec 1999 23:59:59 GMT\r\n"
 | |
|           + "Content-Type: text/html\r\n"
 | |
|           + "Content-Length: " + 9 + "\r\n"
 | |
|           + "\r\n"
 | |
|           + "Not Found"
 | |
|           );
 | |
|           conn.end();
 | |
|           return;
 | |
|         }
 | |
| 
 | |
| 
 | |
|         // Poor-man's http proxy
 | |
|         // XXX SECURITY XXX: should strip existing X-Forwarded headers
 | |
|         newHeads =
 | |
|           [ "X-Forwarded-Proto: " + (opts.encrypted ? 'https' : 'http')
 | |
|           , "X-Forwarded-For: " + (opts.remoteAddress || conn.remoteAddress)
 | |
|           , "X-Forwarded-Host: " + hostname
 | |
|           ];
 | |
| 
 | |
|         if (!opts.encrypted) {
 | |
|           // a exists-only header that a bad client could not remove
 | |
|           newHeads.push("X-Not-Encrypted: yes");
 | |
|         }
 | |
|         if (opts.servername) {
 | |
|           newHeads.push("X-Forwarded-Sni: " + opts.servername);
 | |
|           if (opts.servername !== hostname) {
 | |
|             // an exists-only header that a bad client could not remove
 | |
|             newHeads.push("X-Two-Servernames: yes");
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         firstChunk = firstChunk.toString('utf8');
 | |
|         // JSON.stringify("Host: example.com\r\nNext: Header".replace(/(Host: [^\r\n]*)/i, "$1" + "\r\n" + "X: XYZ"))
 | |
|         firstChunk = firstChunk.replace(/(Host: [^\r\n]*)/i, "$1" + "\r\n" + newHeads.join("\r\n"));
 | |
| 
 | |
|         process.nextTick(function () {
 | |
|           conn.unshift(Buffer.from(firstChunk, 'utf8'));
 | |
|         });
 | |
| 
 | |
|         //
 | |
|         // hard-coded routes for the admin interface
 | |
|         if (
 | |
|           /\blocalhost\.admin\./.test(hostname) || /\badmin\.localhost\./.test(hostname)
 | |
|           || /\blocalhost\.alpha\./.test(hostname) || /\balpha\.localhost\./.test(hostname)
 | |
|         ) {
 | |
|           if (!modules.admin) {
 | |
|             modules.admin = require('./modules/admin.js').create(deps, config);
 | |
|           }
 | |
|           modules.admin.emit('connection', conn);
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // TODO static file handiling and such or whatever
 | |
|         if (!modules.http) {
 | |
|           modules.http = require('./modules/http.js').create(deps, config);
 | |
|         }
 | |
|         opts.hostname = hostname;
 | |
|         conn.__opts = opts;
 | |
| 
 | |
|         modules.http.emit('connection', conn);
 | |
|       };
 | |
|     }
 | |
|   , get: function getTcpRouter(address, port) {
 | |
|       address = address || '0.0.0.0';
 | |
| 
 | |
|       var id = address + ':' + port;
 | |
|       if (!tcpRouter._map[id]) {
 | |
|         tcpRouter._map[id] = tcpRouter._create(address, port);
 | |
|       }
 | |
| 
 | |
|       return tcpRouter._map[id];
 | |
|     }
 | |
|   };
 | |
|   var tlsRouter = {
 | |
|     _map: { }
 | |
|   , _create: function (address, port/*, nextServer*/) {
 | |
|       // port provides hinting for https, smtps, etc
 | |
|       return function (socket, firstChunk, opts) {
 | |
|         if (opts.hyperPeek) {
 | |
|           // See "PEEK COMMENT" for more info
 | |
|           // This was peeked at properly, so we don't have to re-wrap it
 | |
|           // in order to get the connection to not hang.
 | |
|           // The real first 'data' and 'readable' events will occur as they should
 | |
|           program.tlsTunnelServer.emit('connection', socket);
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         var servername = opts.servername;
 | |
|         var packerStream = require('tunnel-packer').Stream;
 | |
|         var myDuplex = packerStream.create(socket);
 | |
| 
 | |
|         myDuplex.remoteAddress = opts.remoteAddress || myDuplex.remoteAddress;
 | |
|         myDuplex.remotePort = opts.remotePort || myDuplex.remotePort;
 | |
|         console.log('[tlsRouter] ' + address + ':' + port + ' servername', servername, myDuplex.remoteAddress);
 | |
| 
 | |
|         // needs to wind up in one of 3 states:
 | |
|         // 1. SNI-based Proxy / Tunnel (we don't even need to put it through the tlsSocket)
 | |
|         // 2. Admin Interface (skips the proxying)
 | |
|         // 3. Terminated (goes on to a particular module or route)
 | |
|         //myDuplex.__tlsTerminated = true;
 | |
| 
 | |
|         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);
 | |
|         });
 | |
| 
 | |
|         // nextServer.emit could be used here
 | |
|         program.tlsTunnelServer.emit('connection', myDuplex);
 | |
| 
 | |
|         // 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)
 | |
|         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();
 | |
|         });
 | |
|       };
 | |
|     }
 | |
|   , get: function getTcpRouter(address, port) {
 | |
|       address = address || '0.0.0.0';
 | |
| 
 | |
|       var id = address + ':' + port;
 | |
|       if (!tlsRouter._map[id]) {
 | |
|         tlsRouter._map[id] = tlsRouter._create(address, port);
 | |
|       }
 | |
| 
 | |
|       return tlsRouter._map[id];
 | |
|     }
 | |
|   };
 | |
| 
 | |
| 
 | |
|   // opts = { servername, encrypted, peek, data, remoteAddress, remotePort }
 | |
|   function peek(conn, firstChunk, 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]*/) {
 | |
|       console.log('tryTls');
 | |
|       opts.servername = (parseSni(firstChunk)||'').toLowerCase() || 'localhost.invalid';
 | |
|       tlsRouter.get(opts.localAddress || conn.localAddress, conn.localPort)(conn, firstChunk, opts);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     console.log('tryTcp');
 | |
| 
 | |
|     if (opts.hyperPeek) {
 | |
|       // even though we've already peeked, this logic is just as well to let be
 | |
|       // since it works properly either way, unlike the tls socket
 | |
|       conn.once('data', function (chunk) {
 | |
|         console.log('hyperPeek re-peek data', chunk.toString('utf8'));
 | |
|         tcpRouter.get(opts.localAddress || conn.localAddress, conn.localPort)(conn, chunk, opts);
 | |
|       });
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     tcpRouter.get(opts.localAddress || conn.localAddress, conn.localPort)(conn, firstChunk, opts);
 | |
|   }
 | |
|   function netHandler(conn, opts) {
 | |
|     opts = opts || {};
 | |
|     console.log('[netHandler]', conn.localAddres, conn.localPort, opts.encrypted);
 | |
| 
 | |
|     // XXX PEEK COMMENT XXX
 | |
|     // TODO we can have our cake and eat it too
 | |
|     // we can skip the need to wrap the TLS connection twice
 | |
|     // because we've already peeked at the data,
 | |
|     // but this needs to be handled better before we enable that
 | |
|     // (because it creates new edge cases)
 | |
|     if (opts.hyperPeek) {
 | |
|       console.log('hyperpeek');
 | |
|       peek(conn, opts.firstChunk, opts);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     conn.once('data', function (chunk) {
 | |
|       peek(conn, chunk, opts);
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   function dnsListener(msg) {
 | |
|     var dgram = require('dgram');
 | |
|     var socket = dgram.createSocket('udp4');
 | |
|     socket.send(msg, config.dns.proxy.port, config.dns.proxy.address || '127.0.0.1');
 | |
|   }
 | |
| 
 | |
|   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) {
 | |
|       console.log('[gl.tunnel] creating connection');
 | |
| 
 | |
|       // here "reader" means the socket that looks like the connection being accepted
 | |
|       // here "writer" means the remote-looking part of the socket that driving the connection
 | |
|       var writer;
 | |
|       var wrapOpts = {};
 | |
|       var rawTls = opts.tls || (0x16 === opts.data[0]) && (0x01 === opts.data[5]);
 | |
| 
 | |
|       function usePair(err, reader) {
 | |
|         if (err) {
 | |
|           process.nextTick(function () {
 | |
|             writer.emit('error', err);
 | |
|           });
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // this has the normal net/tcp stuff plus our custom stuff
 | |
|         // opts = { address, port,
 | |
|         //          hostname, servername, tls, encrypted, data, localAddress, localPort, remoteAddress, remotePort, remoteFamily }
 | |
|         Object.keys(opts).forEach(function (key) {
 | |
|           wrapOpts[key] = opts[key];
 | |
|           try {
 | |
|             reader[key] = opts[key];
 | |
|           } catch(e) {
 | |
|             // can't set real socket getters, like remoteAddr
 | |
|           }
 | |
|         });
 | |
| 
 | |
|         // A few more extra specialty options
 | |
|         wrapOpts.localAddress = wrapOpts.localAddress || '127.0.0.2'; // TODO use the tunnel's external address
 | |
|         wrapOpts.localPort = wrapOpts.localPort || 'tunnel-0';
 | |
|         try {
 | |
|           reader._remoteAddress = wrapOpts.remoteAddress;
 | |
|           reader._remotePort = wrapOpts.remotePort;
 | |
|           reader._remoteFamily = wrapOpts.remoteFamily;
 | |
|           reader._localAddress = wrapOpts.localAddress;
 | |
|           reader._localPort = wrapOpts.localPort;
 | |
|           reader._localFamily = wrapOpts.localFamily;
 | |
|         } catch(e) {
 | |
|         }
 | |
| 
 | |
|         netHandler(reader, wrapOpts);
 | |
| 
 | |
|         process.nextTick(function () {
 | |
|           //opts.data = wrapOpts.data;
 | |
| 
 | |
|           // this cb will cause the stream to emit its (actually) first data event
 | |
|           // (even though it already gave a peek into that first data chunk)
 | |
|           console.log('[tunnel] callback, data should begin to flow');
 | |
|           cb();
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       wrapOpts.firstChunk = opts.data;
 | |
|       wrapOpts.hyperPeek = !!opts.data;
 | |
|       // encrypted meaning is *terminated* TLS
 | |
|       // tls meaning is *raw* TLS
 | |
|       if (rawTls) {
 | |
|         // TLS sockets must actually use a socket with a file descriptor
 | |
|         // https://nodejs.org/api/net.html#net_class_net_socket
 | |
| 
 | |
|         writer = require('socket-pair').create(function (err, other) {
 | |
|           usePair(err, other);
 | |
|         });
 | |
|       }
 | |
|       else {
 | |
|         // stream-pair can only be used by TCP sockets, not tls
 | |
|         writer = require('stream-pair').create();
 | |
|         usePair(null, writer.other);
 | |
|       }
 | |
| 
 | |
|       return writer;
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   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 = config.tcp.ports.map(function (port) {
 | |
|     return listeners.tcp.add(port, netHandler);
 | |
|   });
 | |
| 
 | |
|   if (config.dns.bind) {
 | |
|     if (Array.isArray(config.dns.bind)) {
 | |
|       listenPromises = listenPromises.concat(config.dns.bind.map(function (port) {
 | |
|         return listeners.udp.add(port, dnsListener);
 | |
|       }));
 | |
|     } else {
 | |
|       listenPromises.push(listeners.udp.add(config.dns.bind, dnsListener));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return PromiseA.all(listenPromises);
 | |
| };
 |