goldilocks.js/lib/modules/http.js

328 lines
10 KiB
JavaScript

'use strict';
module.exports.create = function (deps, conf, greenlockMiddleware) {
var PromiseA = require('bluebird');
var express = require('express');
var app = express();
var domainMatches = require('../domain-utils').match;
var separatePort = require('../domain-utils').separatePort;
var adminDomains = [
/\blocalhost\.admin\./
, /\blocalhost\.alpha\./
, /\badmin\.localhost\./
, /\balpha\.localhost\./
];
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')) {
resolve(opts.firstChunk.toString());
conn.removeListener('error', handleErr);
} else {
conn.once('data', handleChunk);
}
}
}
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 moduleMatchesHost(req, mod) {
var host = separatePort((req.headers || req).host).host;
return mod.domains.some(function (pattern) {
return domainMatches(pattern, host);
});
}
// 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)) {
if (!conf.http.primaryDomain) {
(conf.http.modules || []).some(function (mod) {
return mod.domains.some(function (domain) {
if (domain[0] !== '*') {
conf.http.primaryDomain = domain;
return true;
}
});
});
}
if (conf.http.primaryDomain) {
req.headers.host = conf.http.primaryDomain + (host.port ? ':'+host.port : '');
}
}
redirecters[host.port](req, res);
}
function respond404(req, res) {
res.writeHead(404);
res.end('Not Found');
}
function createRedirectRoute(mod) {
// 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 + '/?$');
return function (req, res, next) {
if (!moduleMatchesHost(req, mod)) {
next();
return;
}
var match = fromRe.exec(req.url);
if (!match) {
next();
return;
}
var to = mod.to;
match.slice(1).forEach(function (globMatch, index) {
to = to.replace(':'+(index+1), globMatch);
});
res.writeHead(mod.status || 301, { 'Location': to });
res.end();
};
}
function createStaticRoute(mod) {
var getStaticApp, staticApp;
if (/:hostname/.test(mod.root)) {
staticApp = {};
getStaticApp = function (hostname) {
if (!staticApp[hostname]) {
staticApp[hostname] = express.static(mod.root.replace(':hostname', hostname));
}
return staticApp[hostname];
};
}
else {
staticApp = express.static(mod.root);
getStaticApp = function () {
return staticApp;
};
}
return function (req, res, next) {
if (moduleMatchesHost(req, mod)) {
getStaticApp(separatePort(req.headers.host).host)(req, res, next);
} else {
next();
}
};
}
(conf.http.modules || []).forEach(function (mod) {
if (mod.name === 'redirect') {
app.use(createRedirectRoute(mod));
}
else if (mod.name === 'static') {
app.use(createStaticRoute(mod));
}
else if (mod.name !== 'proxy') {
console.warn('unknown HTTP module', mod);
}
});
app.use(respond404);
var server = require('http').createServer(app);
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);
});
// 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 (!acmeServer) {
acmeServer = require('http').createServer(greenlockMiddleware);
}
return emitConnection(acmeServer, 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 adminServer;
function checkAdmin(conn, opts, headers) {
var admin = adminDomains.some(function (re) {
return re.test(headers.host);
});
if (admin) {
if (!adminServer) {
adminServer = require('./admin').create(deps, conf);
}
return emitConnection(adminServer, conn, opts);
}
return false;
}
function checkProxy(mod, conn, opts, headers) {
if (!moduleMatchesHost(headers, mod)) {
return false;
}
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.
if (conf.http.trustProxy && headers['x-forwarded-proto']) {
headLines.push('X-Forwarded-Proto: ' + headers['x-forwarded-proto']);
} else {
headLines.push('X-Forwarded-Proto: ' + conn.encrypted ? 'https' : 'http');
}
var proxyChain = (headers['x-forwarded-for'] || '').split(/ *, */).filter(Boolean);
proxyChain.push(opts.remoteAddress || opts.address || conn.remoteAddress);
headLines.push('X-Forwarded-For: ' + proxyChain.join(', '));
headLines.push('X-Forwarded-Host: ' + headers.host);
// 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.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);
return true;
}
function handleConnection(conn) {
var opts = conn.__opts;
parseHeaders(conn, opts)
.then(function (headers) {
if (checkACME(conn, opts, headers)) { return; }
if (checkHttps(conn, opts, headers)) { return; }
if (checkAdmin(conn, opts, headers)) { return; }
var handled = (conf.http.modules || []).some(function (mod) {
if (mod.name === 'proxy') {
return checkProxy(mod, conn, opts, headers);
}
});
if (handled) {
return;
}
emitConnection(server, conn, opts);
})
;
}
return {
emit: function (type, value) {
if (type === 'connection') {
handleConnection(value);
}
}
};
};