forked from coolaj86/goldilocks.js
Note that with the way it is currently, proxying modules take priority over other modules even if they come later in the list.
345 lines
9.7 KiB
JavaScript
345 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
module.exports.create = function (deps, conf, greenlockMiddleware) {
|
|
var PromiseA = require('bluebird');
|
|
var express = require('express');
|
|
var app = express();
|
|
var adminApp = require('./admin').create(deps, conf);
|
|
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);
|
|
});
|
|
}
|
|
|
|
function verifyHost(fullHost) {
|
|
var host = separatePort(fullHost).host;
|
|
|
|
if (host === 'localhost') {
|
|
return fullHost.replace(host, 'localhost.daplie.me');
|
|
}
|
|
|
|
// 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 (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || /^\[[0-9a-fA-F:]+\]$/.test(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;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
return fullHost.replace(host, conf.http.primaryDomain || host);
|
|
}
|
|
|
|
return fullHost;
|
|
}
|
|
|
|
// 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 = {};
|
|
function redirectHttps(req, res, next) {
|
|
if (conf.http.allowInsecure) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
var port = separatePort(req.headers.host).port;
|
|
if (!redirecters[port]) {
|
|
redirecters[port] = require('redirect-https')({
|
|
port: port
|
|
, trustProxy: conf.http.trustProxy
|
|
});
|
|
}
|
|
|
|
// 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.
|
|
req.headers.host = verifyHost(req.headers.host);
|
|
|
|
redirecters[port](req, res, next);
|
|
}
|
|
|
|
function handleAdmin(req, res, next) {
|
|
var admin = adminDomains.some(function (re) {
|
|
return re.test(req.headers.host);
|
|
});
|
|
|
|
if (admin) {
|
|
adminApp(req, res);
|
|
} else {
|
|
next();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
};
|
|
}
|
|
|
|
app.use(greenlockMiddleware);
|
|
app.use(redirectHttps);
|
|
app.use(handleAdmin);
|
|
|
|
(conf.http.modules || []).forEach(function (mod) {
|
|
if (mod.name === 'redirect') {
|
|
app.use(createRedirectRoute(mod));
|
|
}
|
|
else if (mod.name === 'static') {
|
|
app.use(createStaticRoute(mod));
|
|
}
|
|
else {
|
|
console.warn('unknown HTTP module', mod);
|
|
}
|
|
});
|
|
|
|
app.use(respond404);
|
|
|
|
var server = require('http').createServer(app);
|
|
|
|
function handleHttp(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;
|
|
}
|
|
|
|
function checkACME(conn, opts, headers) {
|
|
if (headers.url.indexOf('/.well-known/acme-challenge/') !== 0) {
|
|
return false;
|
|
}
|
|
|
|
return handleHttp(conn, opts);
|
|
}
|
|
|
|
function checkRedirect(conn, opts, headers) {
|
|
if (conf.http.allowInsecure || conn.encrypted) {
|
|
return false;
|
|
}
|
|
if (conf.http.trustProxy && 'https' === headers['x-forwarded-proto']) {
|
|
return false;
|
|
}
|
|
|
|
return handleHttp(conn, opts);
|
|
}
|
|
|
|
function checkAdmin(conn, opts, headers) {
|
|
var admin = adminDomains.some(function (re) {
|
|
return re.test(headers.host);
|
|
});
|
|
|
|
if (admin) {
|
|
return handleHttp(conn, opts);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function checkProxy(mod, conn, opts, headers) {
|
|
if (!moduleMatchesHost(headers, mod)) {
|
|
return false;
|
|
}
|
|
|
|
var connected = false;
|
|
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;
|
|
|
|
var newConn = deps.net.createConnection(newConnOpts, function () {
|
|
connected = true;
|
|
newConn.write(opts.firstChunk);
|
|
newConn.pipe(conn);
|
|
conn.pipe(newConn);
|
|
});
|
|
|
|
// Not sure how to effectively report this to the user or client, but we need to listen
|
|
// for the event to prevent it from crashing us.
|
|
newConn.on('error', function (err) {
|
|
if (connected) {
|
|
console.error('HTTP proxy remote error', err);
|
|
conn.end();
|
|
} else {
|
|
require('../proxy-err-resp').sendBadGateway(conn, err, conf.debug);
|
|
}
|
|
});
|
|
conn.on('error', function (err) {
|
|
console.error('HTTP proxy client error', err);
|
|
newConn.end();
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
function handleConnection(conn) {
|
|
var opts = conn.__opts;
|
|
parseHeaders(conn, opts)
|
|
.then(function (headers) {
|
|
if (checkACME(conn, opts, headers)) { return; }
|
|
if (checkRedirect(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;
|
|
}
|
|
|
|
server.emit('connection', conn);
|
|
process.nextTick(function () {
|
|
conn.unshift(opts.firstChunk);
|
|
});
|
|
})
|
|
;
|
|
}
|
|
|
|
return {
|
|
emit: function (type, value) {
|
|
if (type === 'connection') {
|
|
handleConnection(value);
|
|
}
|
|
}
|
|
};
|
|
};
|