544 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			544 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| module.exports.create = function (deps, conf, tcpMods) {
 | |
|   var PromiseA = require('bluebird');
 | |
|   var statAsync = PromiseA.promisify(require('fs').stat);
 | |
|   var domainMatches = require('../domain-utils').match;
 | |
|   var separatePort = require('../domain-utils').separatePort;
 | |
| 
 | |
|   function parseHeaders(conn, opts) {
 | |
|     // There should already be a `firstChunk` on the opts, but because we might sometimes
 | |
|     // need more than that to get all the headers it's easier to always read the data off
 | |
|     // the connection and put it back later if we need to.
 | |
|     opts.firstChunk = Buffer.alloc(0);
 | |
| 
 | |
|     // First we make sure we have all of the headers.
 | |
|     return new PromiseA(function (resolve, reject) {
 | |
|       if (opts.firstChunk.includes('\r\n\r\n')) {
 | |
|         resolve(opts.firstChunk.toString());
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       var errored = false;
 | |
|       function handleErr(err) {
 | |
|         errored = true;
 | |
|         reject(err);
 | |
|       }
 | |
|       conn.once('error', handleErr);
 | |
| 
 | |
|       function handleChunk(chunk) {
 | |
|         if (!errored) {
 | |
|           opts.firstChunk = Buffer.concat([opts.firstChunk, chunk]);
 | |
|           if (!opts.firstChunk.includes('\r\n\r\n')) {
 | |
|             conn.once('data', handleChunk);
 | |
|             return;
 | |
|           }
 | |
| 
 | |
|           conn.removeListener('error', handleErr);
 | |
|           conn.pause();
 | |
|           resolve(opts.firstChunk.toString());
 | |
|         }
 | |
|       }
 | |
|       conn.once('data', handleChunk);
 | |
|     }).then(function (firstStr) {
 | |
|       var headerSection = firstStr.split('\r\n\r\n')[0];
 | |
|       var lines = headerSection.split('\r\n');
 | |
|       var result = {};
 | |
| 
 | |
|       lines.slice(1).forEach(function (line) {
 | |
|         var match = /([^:]*?)\s*:\s*(.*)/.exec(line);
 | |
|         if (match) {
 | |
|           result[match[1].toLowerCase()] = match[2];
 | |
|         } else {
 | |
|           console.error('HTTP header line does not match pattern', line);
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       var match = /^([a-zA-Z]+)\s+(\S+)\s+HTTP/.exec(lines[0]);
 | |
|       if (!match) {
 | |
|         throw new Error('first line of "HTTP" does not match pattern: '+lines[0]);
 | |
|       }
 | |
|       result.method = match[1].toUpperCase();
 | |
|       result.url = match[2];
 | |
| 
 | |
|       return result;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   function hostMatchesDomains(req, domainList) {
 | |
|     var host = separatePort((req.headers || req).host).host.toLowerCase();
 | |
| 
 | |
|     return domainList.some(function (pattern) {
 | |
|       return domainMatches(pattern, host);
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   function determinePrimaryHost() {
 | |
|     var result;
 | |
|     if (Array.isArray(conf.domains)) {
 | |
|       conf.domains.some(function (dom) {
 | |
|         if (!dom.modules || !dom.modules.http) {
 | |
|           return false;
 | |
|         }
 | |
|         return dom.names.some(function (domain) {
 | |
|           if (domain[0] !== '*') {
 | |
|             result = domain;
 | |
|             return true;
 | |
|           }
 | |
|         });
 | |
|       });
 | |
|     }
 | |
|     if (result) {
 | |
|       return result;
 | |
|     }
 | |
| 
 | |
|     if (Array.isArray(conf.http.modules)) {
 | |
|       conf.http.modules.some(function (mod) {
 | |
|         return mod.domains.some(function (domain) {
 | |
|           if (domain[0] !== '*') {
 | |
|             result = domain;
 | |
|             return true;
 | |
|           }
 | |
|         });
 | |
|       });
 | |
|     }
 | |
|     return result;
 | |
|   }
 | |
| 
 | |
|   // We handle both HTTPS and HTTP traffic on the same ports, and we want to redirect
 | |
|   // any unencrypted requests to the same port they came from unless it came in on
 | |
|   // the default HTTP port, in which case there wont be a port specified in the host.
 | |
|   var redirecters = {};
 | |
|   var ipv4Re = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
 | |
|   var ipv6Re = /^\[[0-9a-fA-F:]+\]$/;
 | |
|   function redirectHttps(req, res) {
 | |
|     var host = separatePort(req.headers.host);
 | |
| 
 | |
|     if (!redirecters[host.port]) {
 | |
|       redirecters[host.port] = require('redirect-https')({ port: host.port });
 | |
|     }
 | |
| 
 | |
|     // localhost and IP addresses cannot have real SSL certs (and don't contain any useful
 | |
|     // info for redirection either), so we direct some hosts to either localhost.daplie.me
 | |
|     // or the "primary domain" ie the first manually specified domain.
 | |
|     if (host.host === 'localhost') {
 | |
|       req.headers.host = 'localhost.daplie.me' + (host.port ? ':'+host.port : '');
 | |
|     }
 | |
|     // Test for IPv4 and IPv6 addresses. These patterns will match some invalid addresses,
 | |
|     // but since those still won't be valid domains that won't really be a problem.
 | |
|     if (ipv4Re.test(host.host) || ipv6Re.test(host.host)) {
 | |
|       var dest;
 | |
|       if (conf.http.primaryDomain) {
 | |
|         dest = conf.http.primaryDomain;
 | |
|       } else {
 | |
|         dest = determinePrimaryHost();
 | |
|       }
 | |
|       if (dest) {
 | |
|         req.headers.host = dest + (host.port ? ':'+host.port : '');
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     redirecters[host.port](req, res);
 | |
|   }
 | |
| 
 | |
|   function emitConnection(server, conn, opts) {
 | |
|     server.emit('connection', conn);
 | |
| 
 | |
|     // We need to put back whatever data we read off to determine the connection was HTTP
 | |
|     // and to parse the headers. Must be done after data handlers added but before any new
 | |
|     // data comes in.
 | |
|     process.nextTick(function () {
 | |
|       conn.unshift(opts.firstChunk);
 | |
|       conn.resume();
 | |
|     });
 | |
| 
 | |
|     // Convenience return for all the check* functions.
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   var acmeServer;
 | |
|   function checkAcme(conn, opts, headers) {
 | |
|     if (headers.url.indexOf('/.well-known/acme-challenge/') !== 0) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     if (deps.stunneld.isClientDomain(separatePort(headers.host).host)) {
 | |
|       deps.stunneld.handleClientConn(conn);
 | |
|       process.nextTick(function () {
 | |
|         conn.unshift(opts.firstChunk);
 | |
|         conn.resume();
 | |
|       });
 | |
|       return true;
 | |
|     }
 | |
| 
 | |
|     if (!acmeServer) {
 | |
|       acmeServer = require('http').createServer(tcpMods.tls.middleware);
 | |
|     }
 | |
|     return emitConnection(acmeServer, conn, opts);
 | |
|   }
 | |
| 
 | |
|   function checkLoopback(conn, opts, headers) {
 | |
|     if (headers.url.indexOf('/.well-known/cloud-challenge/') !== 0) {
 | |
|       return false;
 | |
|     }
 | |
|     return emitConnection(deps.ddns.loopbackServer, conn, opts);
 | |
|   }
 | |
| 
 | |
|   var httpsRedirectServer;
 | |
|   function checkHttps(conn, opts, headers) {
 | |
|     if (conf.http.allowInsecure || conn.encrypted) {
 | |
|       return false;
 | |
|     }
 | |
|     if (conf.http.trustProxy && 'https' === headers['x-forwarded-proto']) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     if (!httpsRedirectServer) {
 | |
|       httpsRedirectServer = require('http').createServer(redirectHttps);
 | |
|     }
 | |
|     return emitConnection(httpsRedirectServer, conn, opts);
 | |
|   }
 | |
| 
 | |
|   var adminDomains;
 | |
|   var adminServer;
 | |
|   function checkAdmin(conn, opts, headers) {
 | |
|     var host = separatePort(headers.host).host;
 | |
| 
 | |
|     if (!adminDomains) {
 | |
|       adminDomains = require('../admin').adminDomains;
 | |
|     }
 | |
|     if (adminDomains.indexOf(host) !== -1) {
 | |
|       if (!adminServer) {
 | |
|         adminServer = require('../admin').create(deps, conf);
 | |
|       }
 | |
|       return emitConnection(adminServer, conn, opts);
 | |
|     }
 | |
| 
 | |
|     if (deps.stunneld.isAdminDomain(host)) {
 | |
|       deps.stunneld.handleAdminConn(conn);
 | |
|       process.nextTick(function () {
 | |
|         conn.unshift(opts.firstChunk);
 | |
|         conn.resume();
 | |
|       });
 | |
|       return true;
 | |
|     }
 | |
| 
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   var proxyServer;
 | |
|   function createProxyServer() {
 | |
|     var http = require('http');
 | |
|     var agent = new http.Agent();
 | |
|     agent.createConnection = deps.net.createConnection;
 | |
| 
 | |
|     var proxy = require('http-proxy').createProxyServer({
 | |
|       agent: agent
 | |
|     , toProxy: true
 | |
|     });
 | |
| 
 | |
|     proxy.on('error', function (err, req, res) {
 | |
|       res.statusCode = 502;
 | |
|       res.setHeader('Connection', 'close');
 | |
|       res.setHeader('Content-Type', 'text/html');
 | |
|       res.end(tcpMods.proxy.getRespBody(err, conf.debug));
 | |
|     });
 | |
| 
 | |
|     proxyServer = http.createServer(function (req, res) {
 | |
|       proxy.web(req, res, req.connection.proxyOpts);
 | |
|     });
 | |
|     proxyServer.on('upgrade', function (req, socket, head) {
 | |
|       proxy.ws(req, socket, head, socket.proxyOpts);
 | |
|     });
 | |
|   }
 | |
|   function proxyRequest(mod, conn, opts, xHeaders) {
 | |
|     if (!proxyServer) {
 | |
|       createProxyServer();
 | |
|     }
 | |
| 
 | |
|     conn.proxyOpts = {
 | |
|       target: 'http://'+(mod.address || (mod.host || 'localhost')+':'+mod.port)
 | |
|     , headers: xHeaders
 | |
|     };
 | |
|     return emitConnection(proxyServer, conn, opts);
 | |
|   }
 | |
| 
 | |
|   function proxyWebsocket(mod, conn, opts, headers, xHeaders) {
 | |
|     var index = opts.firstChunk.indexOf('\r\n\r\n');
 | |
|     var body = opts.firstChunk.slice(index);
 | |
| 
 | |
|     var head = opts.firstChunk.slice(0, index).toString();
 | |
|     var headLines = head.split('\r\n');
 | |
|     // First strip any existing `X-Forwarded-*` headers (for security purposes?)
 | |
|     headLines = headLines.filter(function (line) {
 | |
|       return !/^x-forwarded/i.test(line);
 | |
|     });
 | |
|     // Then add our own `X-Forwarded` headers at the end.
 | |
|     Object.keys(xHeaders).forEach(function (key) {
 | |
|       headLines.push(key + ': ' +xHeaders[key]);
 | |
|     });
 | |
|     // Then convert all of the head lines back into a header buffer.
 | |
|     head = Buffer.from(headLines.join('\r\n'));
 | |
| 
 | |
|     opts.firstChunk = Buffer.concat([head, body]);
 | |
| 
 | |
|     var newConnOpts = separatePort(mod.address || '');
 | |
|     newConnOpts.port = newConnOpts.port || mod.port;
 | |
|     newConnOpts.host = newConnOpts.host || mod.host || 'localhost';
 | |
|     newConnOpts.servername = separatePort(headers.host).host;
 | |
|     newConnOpts.data = opts.firstChunk;
 | |
| 
 | |
|     newConnOpts.remoteFamily  = opts.family  || conn.remoteFamily;
 | |
|     newConnOpts.remoteAddress = opts.address || conn.remoteAddress;
 | |
|     newConnOpts.remotePort    = opts.port    || conn.remotePort;
 | |
| 
 | |
|     tcpMods.proxy(conn, newConnOpts, opts.firstChunk);
 | |
|   }
 | |
| 
 | |
|   function checkProxy(mod, conn, opts, headers) {
 | |
|     var xHeaders = {};
 | |
|     // Then add our own `X-Forwarded` headers at the end.
 | |
|     if (conf.http.trustProxy && headers['x-forwarded-proto']) {
 | |
|       xHeaders['X-Forwarded-Proto'] = headers['x-forwarded-proto'];
 | |
|     } else {
 | |
|       xHeaders['X-Forwarded-Proto'] = conn.encrypted ? 'https' : 'http';
 | |
|     }
 | |
|     var proxyChain = (headers['x-forwarded-for'] || '').split(/ *, */).filter(Boolean);
 | |
|     proxyChain.push(opts.remoteAddress || opts.address || conn.remoteAddress);
 | |
|     xHeaders['X-Forwarded-For'] = proxyChain.join(', ');
 | |
|     xHeaders['X-Forwarded-Host'] = headers.host;
 | |
| 
 | |
|     if ((headers.connection || '').toLowerCase() === 'upgrade') {
 | |
|       proxyWebsocket(mod, conn, opts, headers, xHeaders);
 | |
|     } else {
 | |
|       proxyRequest(mod, conn, opts, xHeaders);
 | |
|     }
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   function checkRedirect(mod, conn, opts, headers) {
 | |
|     if (!mod.fromRe || mod.fromRe.origSrc !== mod.from) {
 | |
|       // Escape any characters that (can) have special meaning in regular expression
 | |
|       // but that aren't the special characters we have interest in.
 | |
|       var from = mod.from.replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&');
 | |
|       // Then modify the characters we are interested in so they do what we want in
 | |
|       // the regular expression after being compiled.
 | |
|       from = from.replace(/\*/g, '(.*)');
 | |
|       var fromRe = new RegExp('^' + from + '/?$');
 | |
|       fromRe.origSrc = mod.from;
 | |
|       // We don't want this property showing up in the actual config file or the API,
 | |
|       // so we define it this way so it's not enumberable.
 | |
|       Object.defineProperty(mod, 'fromRe', {value: fromRe, configurable: true});
 | |
|     }
 | |
| 
 | |
|     var match = mod.fromRe.exec(headers.url);
 | |
|     if (!match) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     var to = mod.to;
 | |
|     match.slice(1).forEach(function (globMatch, index) {
 | |
|       to = to.replace(':'+(index+1), globMatch);
 | |
|     });
 | |
|     var status = mod.status || 301;
 | |
|     var code = require('http').STATUS_CODES[status] || 'Unknown';
 | |
| 
 | |
|     conn.end([
 | |
|       'HTTP/1.1 ' + status + ' ' + code
 | |
|     , 'Date: ' + (new Date()).toUTCString()
 | |
|     , 'Location: ' + to
 | |
|     , 'Connection: close'
 | |
|     , 'Content-Length: 0'
 | |
|     , ''
 | |
|     , ''
 | |
|     ].join('\r\n'));
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   var staticServer;
 | |
|   var staticHandlers = {};
 | |
|   var indexHandlers = {};
 | |
|   function serveStatic(req, res) {
 | |
|     var rootDir = req.connection.rootDir;
 | |
|     var modOpts = req.connection.modOpts;
 | |
| 
 | |
|     if (!staticHandlers[rootDir]) {
 | |
|       staticHandlers[rootDir] = require('express').static(rootDir, {
 | |
|         dotfiles: modOpts.dotfiles
 | |
|       , fallthrough: false
 | |
|       , redirect: modOpts.redirect
 | |
|       , index: modOpts.index
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     staticHandlers[rootDir](req, res, function (err) {
 | |
|       function doFinal() {
 | |
|         if (err) {
 | |
|           res.statusCode = err.statusCode;
 | |
|         } else {
 | |
|           res.statusCode = 404;
 | |
|         }
 | |
|         res.setHeader('Content-Type', 'text/html');
 | |
| 
 | |
|         if (res.statusCode === 404) {
 | |
|           res.end('File Not Found');
 | |
|         } else {
 | |
|           res.end(require('http').STATUS_CODES[res.statusCode]);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       var handlerHandle = rootDir
 | |
|         + (modOpts.hidden||'')
 | |
|         + (modOpts.icons||'')
 | |
|         + (modOpts.stylesheet||'')
 | |
|         + (modOpts.template||'')
 | |
|         + (modOpts.view||'')
 | |
|         ;
 | |
| 
 | |
|       function pathMatchesUrl(pathname) {
 | |
|         if (req.url === pathname) {
 | |
|           return true;
 | |
|         }
 | |
|         if (0 === req.url.replace(/\/?$/, '/').indexOf(pathname.replace(/\/?$/, '/'))) {
 | |
|           return true;
 | |
|         }
 | |
|       }
 | |
|       if (!modOpts.indexes || ('*' !== modOpts.indexes[0] && !modOpts.indexes.some(pathMatchesUrl))) {
 | |
|         doFinal();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (!indexHandlers[handlerHandle]) {
 | |
|         // https://www.npmjs.com/package/serve-index
 | |
|         indexHandlers[handlerHandle] = require('serve-index')(rootDir, {
 | |
|           hidden: modOpts.hidden
 | |
|         , icons: modOpts.icons
 | |
|         , stylesheet: modOpts.stylesheet
 | |
|         , template: modOpts.template
 | |
|         , view: modOpts.view
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       indexHandlers[handlerHandle](req, res, function (_err) {
 | |
|         err = _err || err;
 | |
| 
 | |
|         doFinal();
 | |
|       });
 | |
| 
 | |
|     });
 | |
|   }
 | |
|   function checkStatic(modOpts, conn, opts, headers) {
 | |
|     var rootDir = modOpts.root.replace(':hostname', separatePort(headers.host).host);
 | |
|     return statAsync(rootDir)
 | |
|       .then(function (stats) {
 | |
|         if (!stats || !stats.isDirectory()) {
 | |
|           return false;
 | |
|         }
 | |
| 
 | |
|         if (!staticServer) {
 | |
|           staticServer = require('http').createServer(serveStatic);
 | |
|         }
 | |
|         conn.rootDir = rootDir;
 | |
|         conn.modOpts = modOpts;
 | |
|         return emitConnection(staticServer, conn, opts);
 | |
|       })
 | |
|       .catch(function (err) {
 | |
|         if (err.code !== 'ENOENT') {
 | |
|           console.warn('errored stating', rootDir, 'for serving static files', err);
 | |
|         }
 | |
|         return false;
 | |
|       })
 | |
|       ;
 | |
|   }
 | |
| 
 | |
|   // The function signature is as follows
 | |
|   // function module(moduleOptions, tcpConnection, connectionOptions, headers) { ... }
 | |
|   var moduleChecks = {
 | |
|     proxy:    checkProxy
 | |
|   , redirect: checkRedirect
 | |
|   , static:   checkStatic
 | |
|   };
 | |
| 
 | |
|   function handleConnection(conn) {
 | |
|     var opts = conn.__opts;
 | |
|     parseHeaders(conn, opts)
 | |
|       .then(function (headers) {
 | |
|         if (checkAcme(conn, opts, headers))  { return; }
 | |
|         if (checkLoopback(conn, opts, headers))  { return; }
 | |
|         if (checkHttps(conn, opts, headers)) { return; }
 | |
|         if (checkAdmin(conn, opts, headers)) { return; }
 | |
| 
 | |
|         var prom = PromiseA.resolve(false);
 | |
|         (conf.domains || []).forEach(function (dom) {
 | |
|           prom = prom.then(function (handled) {
 | |
|             if (handled) {
 | |
|               return handled;
 | |
|             }
 | |
|             if (!dom.modules || !dom.modules.http) {
 | |
|               return false;
 | |
|             }
 | |
|             if (!hostMatchesDomains(headers, dom.names)) {
 | |
|               return false;
 | |
|             }
 | |
| 
 | |
|             var subProm = PromiseA.resolve(false);
 | |
|             dom.modules.http.forEach(function (mod) {
 | |
|               if (moduleChecks[mod.type]) {
 | |
|                 subProm = subProm.then(function (handled) {
 | |
|                   if (handled) { return handled; }
 | |
|                   return moduleChecks[mod.type](mod, conn, opts, headers);
 | |
|                 });
 | |
|               } else {
 | |
|                 console.warn('unknown HTTP module under domains', dom.names.join(','), mod);
 | |
|               }
 | |
|             });
 | |
|             return subProm;
 | |
|           });
 | |
|         });
 | |
|         (conf.http.modules || []).forEach(function (mod) {
 | |
|           prom = prom.then(function (handled) {
 | |
|             if (handled) {
 | |
|               return handled;
 | |
|             }
 | |
|             if (!hostMatchesDomains(headers, mod.domains)) {
 | |
|               return false;
 | |
|             }
 | |
| 
 | |
|             if (moduleChecks[mod.type]) {
 | |
|               return moduleChecks[mod.type](mod, conn, opts, headers);
 | |
|             }
 | |
|             console.warn('unknown HTTP module found', mod);
 | |
|           });
 | |
|         });
 | |
| 
 | |
| 
 | |
|         prom.then(function (handled) {
 | |
|           // XXX TODO SECURITY html escape
 | |
|           var host = (headers.host || '[no host header]').replace(/</, '<');
 | |
|           // TODO specify filepath of config file or database connection, etc
 | |
|           var msg = "Bad Gateway: Goldilocks accepted '" + host + "' but no module (neither static nor proxy) was designated to handle it. Check your config file.";
 | |
|           if (!handled) {
 | |
|             conn.end([
 | |
|               'HTTP/1.1 502 Bad Gateway'
 | |
|             , 'Date: ' + (new Date()).toUTCString()
 | |
|             , 'Content-Type: text/html'
 | |
|             , 'Content-Length: ' + msg.length
 | |
|             , 'Connection: close'
 | |
|             , ''
 | |
|             , msg
 | |
|             ].join('\r\n'));
 | |
|           }
 | |
|         });
 | |
|       })
 | |
|       ;
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     emit: function (type, value) {
 | |
|       if (type === 'connection') {
 | |
|         handleConnection(value);
 | |
|       }
 | |
|     }
 | |
|   };
 | |
| };
 |