forked from coolaj86/goldilocks.js
493 lines
15 KiB
JavaScript
493 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
module.exports.create = function (deps, conf, greenlockMiddleware) {
|
|
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.tunnelServer.isClientDomain(separatePort(headers.host).host)) {
|
|
deps.tunnelServer.handleClientConn(conn);
|
|
process.nextTick(function () {
|
|
conn.unshift(opts.firstChunk);
|
|
conn.resume();
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (!acmeServer) {
|
|
acmeServer = require('http').createServer(greenlockMiddleware);
|
|
}
|
|
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.tunnelServer.isAdminDomain(host)) {
|
|
deps.tunnelServer.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(require('../proxy-conn').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;
|
|
|
|
deps.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 = {};
|
|
function serveStatic(req, res) {
|
|
var rootDir = req.connection.rootDir;
|
|
|
|
if (!staticHandlers[rootDir]) {
|
|
staticHandlers[rootDir] = require('express').static(rootDir, { fallthrough: false });
|
|
}
|
|
|
|
staticHandlers[rootDir](req, res, function (err) {
|
|
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]);
|
|
}
|
|
});
|
|
}
|
|
function checkStatic(mod, conn, opts, headers) {
|
|
var rootDir = mod.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;
|
|
return emitConnection(staticServer, conn, opts);
|
|
})
|
|
.catch(function (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
console.warn('errored stating', rootDir, 'for serving static files', err);
|
|
}
|
|
return false;
|
|
})
|
|
;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
};
|