redbird/lib/proxy.js
2018-06-29 15:47:19 +02:00

781 lines
21 KiB
JavaScript

/*eslint-env node */
'use strict';
var
http = require('http'),
httpProxy = require('http-proxy'),
validUrl = require('valid-url'),
parseUrl = require('url').parse,
path = require('path'),
_ = require('lodash'),
bunyan = require('bunyan'),
cluster = require('cluster'),
hash = require('object-hash'),
LRUCache = require("lru-cache"),
routeCache = LRUCache({ max: 5000 }),
safe = require('safetimeout'),
letsencrypt = require('./letsencrypt.js');
var ONE_DAY = 60 * 60 * 24 * 1000;
var ONE_MONTH = ONE_DAY * 30;
function ReverseProxy(opts) {
if (!(this instanceof ReverseProxy)) {
return new ReverseProxy(opts);
}
this.opts = opts = opts || {};
var log;
if (opts.bunyan !== false) {
log = this.log = bunyan.createLogger(opts.bunyan || {
name: 'redbird'
});
}
var _this = this;
if (opts.cluster && typeof opts.cluster !== 'number' || opts.cluster > 32) {
throw Error('cluster setting must be an integer less than 32');
}
if (opts.cluster && cluster.isMaster) {
for (var i = 0; i < opts.cluster; i++) {
cluster.fork();
}
cluster.on('exit', function (worker, code, signal) {
// Fork if a worker dies.
log && log.error({
code: code,
signal: signal
},
'worker died un-expectedly... restarting it.');
cluster.fork();
});
} else {
this.resolvers = [this._defaultResolver];
opts.port = opts.port || 8080;
if (opts.letsencrypt) {
this.setupLetsencrypt(log, opts);
}
if (opts.resolvers) {
this.addResolver(opts.resolvers);
}
//
// Routing table.
//
this.routing = {};
//
// Create a proxy server with custom application logic
//
var proxy = this.proxy = httpProxy.createProxyServer({
xfwd: (opts.xfwd != false),
prependPath: false,
secure: (opts.secure !== false),
changeOrigin: true,
/*
agent: new http.Agent({
keepAlive: true
})
*/
});
proxy.on('proxyReq', function (p, req) {
if (req.host != null) {
p.setHeader('host', req.host);
}
});
//
// Support NTLM auth
//
if (opts.ntlm) {
proxy.on('proxyRes', function (proxyRes) {
var key = 'www-authenticate';
proxyRes.headers[key] = proxyRes.headers[key] && proxyRes.headers[key].split(',');
});
}
//
// Optionally create an https proxy server.
//
if (opts.ssl) {
if (_.isArray(opts.ssl)) {
opts.ssl.forEach(function (sslOpts) {
_this.setupHttpsProxy(proxy, websocketsUpgrade, log, sslOpts);
})
} else {
this.setupHttpsProxy(proxy, websocketsUpgrade, log, opts.ssl);
}
}
//
// Plain HTTP Proxy
//
var server = this.setupHttpProxy(proxy, websocketsUpgrade, log, opts);
server.listen(opts.port, opts.host);
if (opts.errorHandler && _.isFunction(opts.errorHandler)) {
proxy.on('error', opts.errorHandler);
} else {
proxy.on('error', handleProxyError);
}
log && log.info('Started a Redbird reverse proxy server on port %s', opts.port);
}
function websocketsUpgrade(req, socket, head) {
var src = _this._getSource(req);
var target = _this._getTarget(src, req);
log && log.info({ headers: req.headers, target: target }, 'upgrade to websockets');
if (target) {
proxy.ws(req, socket, head, { target: target });
} else {
respondNotFound(req, socket);
}
}
function handleProxyError(err, req, res) {
//
// Send a 500 http status if headers have been sent
//
if (err.code === 'ECONNREFUSED') {
res.writeHead && res.writeHead(502);
} else if (!res.headersSent) {
res.writeHead && res.writeHead(500);
}
//
// Do not log this common error
//
if (err.message !== 'socket hang up') {
log && log.error(err, 'Proxy Error');
}
//
// TODO: if err.code=ECONNREFUSED and there are more servers
// for this route, try another one.
//
res.end(err.code)
}
}
ReverseProxy.prototype.setupHttpProxy = function (proxy, websocketsUpgrade, log, opts) {
var _this = this;
var httpServerModule = opts.serverModule || http;
var server = this.server = httpServerModule.createServer(function (req, res) {
var src = _this._getSource(req);
var target = _this._getTarget(src, req);
if (target) {
if (shouldRedirectToHttps(_this.certs, src, target, _this)) {
redirectToHttps(req, res, target, opts.ssl, log);
} else {
proxy.web(req, res, { target: target });
}
} else {
respondNotFound(req, res);
}
});
//
// Listen to the `upgrade` event and proxy the
// WebSocket requests as well.
//
server.on('upgrade', websocketsUpgrade);
server.on('error', function (err) {
log && log.error(err, 'Server Error');
});
return server;
}
function shouldRedirectToHttps(certs, src, target, proxy) {
return certs && src in certs && target.sslRedirect && target.host != proxy.letsencryptHost;
}
ReverseProxy.prototype.setupLetsencrypt = function (log, opts) {
if (!opts.letsencrypt.path) {
throw Error('Missing certificate path for Lets Encrypt');
}
var letsencryptPort = opts.letsencrypt.port || 3000;
letsencrypt.init(opts.letsencrypt.path, letsencryptPort, log);
opts.resolvers = opts.resolvers || [];
this.letsencryptHost = '127.0.0.1:' + letsencryptPort;
var targetHost = 'http://' + this.letsencryptHost;
var challengeResolver = function (host, url) {
if (/^\/.well-known\/acme-challenge/.test(url)) {
return targetHost + '/' + host;
}
}
challengeResolver.priority = 9999;
this.addResolver(challengeResolver);
}
ReverseProxy.prototype.setupHttpsProxy = function (proxy, websocketsUpgrade, log, sslOpts) {
var _this = this;
var https;
this.certs = this.certs || {};
var certs = this.certs;
var ssl = {
SNICallback: function (hostname, cb) {
if (cb) {
cb(null, certs[hostname]);
} else {
return certs[hostname];
}
},
//
// Default certs for clients that do not support SNI.
//
key: getCertData(sslOpts.key),
cert: getCertData(sslOpts.cert)
};
if (sslOpts.ca) {
ssl.ca = getCertData(sslOpts.ca, true);
}
if (sslOpts.opts) {
ssl = _.defaults(ssl, sslOpts.opts);
}
if (sslOpts.http2) {
https = sslOpts.serverModule || require('spdy');
if (_.isObject(sslOpts.http2)) {
sslOpts.spdy = sslOpts.http2;
}
} else {
https = sslOpts.serverModule || require('https');
}
var httpsServer = this.httpsServer = https.createServer(ssl, function (req, res) {
var src = _this._getSource(req);
var target = _this._getTarget(src, req);
if (target) {
proxy.web(req, res, { target: target });
} else {
respondNotFound(req, res);
}
});
httpsServer.on('upgrade', websocketsUpgrade);
httpsServer.on('error', function (err) {
log && log.error(err, 'HTTPS Server Error');
});
httpsServer.on('clientError', function (err) {
log && log.error(err, 'HTTPS Client Error');
});
log && log.info('Listening to HTTPS requests on port %s', sslOpts.port);
httpsServer.listen(sslOpts.port, sslOpts.ip);
}
ReverseProxy.prototype.addResolver = function (resolver) {
if (this.opts.cluster && cluster.isMaster) return this;
if (!_.isArray(resolver)) {
resolver = [resolver];
}
var _this = this;
resolver.forEach(function (resolveObj) {
if (!_.isFunction(resolveObj)) {
throw new Error("Resolver must be an invokable function.");
}
if (!resolveObj.hasOwnProperty('priority')) {
resolveObj.priority = 0;
}
_this.resolvers.push(resolveObj);
});
_this.resolvers = _.sortBy(_.uniq(_this.resolvers), function (r) {
return -r.priority;
});
};
ReverseProxy.prototype.removeResolver = function (resolver) {
if (this.opts.cluster && cluster.isMaster) return this;
// since unique resolvers are not checked for performance,
// just remove every existence.
this.resolvers = this.resolvers.filter(function (resolverFn) {
return resolverFn !== resolver;
});
};
ReverseProxy.buildTarget = function (target, opts) {
opts = opts || {};
target = prepareUrl(target);
target.sslRedirect = !opts.ssl || opts.ssl.redirect !== false;
target.useTargetHostHeader = opts.useTargetHostHeader === true;
return target;
};
/**
Register a new route.
@src {String|URL} A string or a url parsed by node url module.
Note that port is ignored, since the proxy just listens to one port.
@target {String|URL} A string or a url parsed by node url module.
@opts {Object} Route options.
*/
ReverseProxy.prototype.register = function (src, target, opts) {
if (this.opts.cluster && cluster.isMaster) return this;
if (!src || !target) {
throw Error('Cannot register a new route with unspecified src or target');
}
var routing = this.routing;
src = prepareUrl(src);
if (opts) {
var ssl = opts.ssl;
if (ssl) {
if (!this.httpsServer) {
throw Error('Cannot register https routes without defining a ssl port');
}
if (!this.certs[src.hostname]) {
if (ssl.key || ssl.cert || ssl.ca) {
this.certs[src.hostname] = createCredentialContext(ssl.key, ssl.cert, ssl.ca);
} else if (ssl.letsencrypt) {
if (!this.opts.letsencrypt || !this.opts.letsencrypt.path) {
console.error('Missing certificate path for Lets Encrypt');
return;
}
this.log && this.log.info('Getting Lets Encrypt certificates for %s', src.hostname);
this.updateCertificates(
src.hostname,
ssl.letsencrypt.email,
ssl.letsencrypt.production,
this.opts.letsencrypt.renewWithin || ONE_MONTH);
} else {
// Trigger the use of the default certificates.
this.certs[src.hostname] = void 0;
}
}
}
}
target = ReverseProxy.buildTarget(target, opts);
var host = routing[src.hostname] = routing[src.hostname] || [];
var pathname = src.pathname || '/';
var route = _.find(host, { path: pathname });
if (!route) {
route = { path: pathname, rr: 0, urls: [] };
host.push(route);
//
// Sort routes
//
routing[src.hostname] = _.sortBy(host, function (_route) {
return -_route.path.length;
});
}
route.urls.push(target);
this.log && this.log.info({ from: src, to: target }, 'Registered a new route');
return this;
};
ReverseProxy.prototype.updateCertificates = function (domain, email, production, renewWithin, renew) {
var _this = this;
return letsencrypt.getCertificates(domain, email, production, renew, this.log).then(function (certs) {
if (certs) {
var opts = {
key: certs.privkey,
cert: certs.cert + certs.chain
}
_this.certs[domain] = tls.createSecureContext(opts).context;
//
// TODO: cluster friendly
//
var renewTime = (certs.expiresAt - Date.now()) - renewWithin;
renewTime = renewTime > 0 ? renewTime : _this.opts.letsencrypt.minRenewTime || 60 * 60 * 1000;
_this.log && _this.log.info('Renewal of %s in %s days', domain, Math.floor(renewTime / ONE_DAY));
function renewCertificate() {
_this.log && _this.log.info('Renewing letscrypt certificates for %s', domain);
_this.updateCertificates(domain, email, production, renewWithin, true);
}
_this.certs[domain].renewalTimeout = safe.setTimeout(renewCertificate, renewTime);
} else {
//
// TODO: Try again, but we need an exponential backof to avoid getting banned.
//
_this.log && _this.log.info('Could not get any certs for %s', domain);
}
}, function (err) {
console.error('Error getting LetsEncrypt certificates', err);
});
};
ReverseProxy.prototype.unregister = function (src, target) {
if (this.opts.cluster && cluster.isMaster) return this;
if (!src) {
return this;
}
src = prepareUrl(src);
var routes = this.routing[src.hostname] || [];
var pathname = src.pathname || '/';
var i;
for (i = 0; i < routes.length; i++) {
if (routes[i].path === pathname) {
break;
}
}
if (i < routes.length) {
var route = routes[i];
if (target) {
target = prepareUrl(target);
_.remove(route.urls, function (url) {
return url.href === target.href;
});
} else {
route.urls = [];
}
if (route.urls.length === 0) {
routes.splice(i, 1);
var certs = this.certs;
if (certs) {
if (certs[src.hostname] && certs[src.hostname].renewalTimeout) {
safe.clearTimeout(certs[src.hostname].renewalTimeout);
}
delete certs[src.hostname];
}
}
this.log && this.log.info({ from: src, to: target }, 'Unregistered a route');
}
return this;
};
ReverseProxy.prototype._defaultResolver = function (host, url) {
// Given a src resolve it to a target route if any available.
if (!host) {
return;
}
url = url || '/';
var routes = this.routing[host];
var i = 0;
if (routes) {
var len = routes.length;
//
// Find path that matches the start of req.url
//
for (i = 0; i < len; i++) {
var route = routes[i];
if (route.path === '/' || startsWith(url, route.path)) {
return route;
}
}
}
};
ReverseProxy.prototype._defaultResolver.priority = 0;
/**
* Resolves to route
* @param host
* @param url
* @returns {*}
*/
ReverseProxy.prototype.resolve = function (host, url) {
var route;
host = host && host.toLowerCase();
for (var i = 0; i < this.resolvers.length; i++) {
route = this.resolvers[i].call(this, host, url);
if (route && (route = ReverseProxy.buildRoute(route))) {
// ensure resolved route has path that prefixes URL
// no need to check for native routes.
if (!route.isResolved || route.path === '/' || startsWith(url, route.path)) {
return route;
}
}
}
};
ReverseProxy.buildRoute = function (route) {
if (!_.isString(route) && !_.isObject(route)) {
return null;
}
if (_.isObject(route) && route.hasOwnProperty('urls') && route.hasOwnProperty('path')) {
// default route type matched.
return route;
}
var cacheKey = _.isString(route) ? route : hash(route);
var entry = routeCache.get(cacheKey);
if (entry) {
return entry;
}
var routeObject = { rr: 0, isResolved: true };
if (_.isString(route)) {
routeObject.urls = [ReverseProxy.buildTarget(route)];
routeObject.path = '/';
} else {
if (!route.hasOwnProperty('url')) {
return null;
}
routeObject.urls = (_.isArray(route.url) ? route.url : [route.url]).map(function (url) {
return ReverseProxy.buildTarget(url, route.opts || {});
});
routeObject.path = route.path || '/';
}
routeCache.set(cacheKey, routeObject);
return routeObject;
};
ReverseProxy.prototype._getTarget = function (src, req) {
var url = req.url;
var route = this.resolve(src, url);
if (!route) {
this.log && this.log.warn({ src: src, url: url }, 'no valid route found for given source');
return;
}
var pathname = route.path;
if (pathname.length > 1) {
//
// remove prefix from src
//
req._url = url; // save original url
req.url = url.substr(pathname.length) || '/';
}
//
// Perform Round-Robin on the available targets
// TODO: if target errors with EHOSTUNREACH we should skip this
// target and try with another.
//
var urls = route.urls;
var j = route.rr;
route.rr = (j + 1) % urls.length; // get and update Round-robin index.
var target = route.urls[j];
//
// Fix request url if targetname specified.
//
if (target.pathname) {
req.url = path.join(target.pathname, req.url);
}
//
// Host headers are passed through from the source by default
// Often we want to use the host header of the target instead
//
if (target.useTargetHostHeader === true) {
req.host = target.host;
}
this.log && this.log.info('Proxying %s to %s', src + url, path.join(target.host, req.url));
return target;
};
ReverseProxy.prototype._getSource = function (req) {
if (this.opts.preferForwardedHost === true && req.headers['x-forwarded-host']) {
return req.headers['x-forwarded-host'].split(':')[0];
}
if (req.headers.host) {
return req.headers.host.split(':')[0];
}
}
ReverseProxy.prototype.close = function () {
try {
this.server.close();
this.httpsServer && this.httpsServer.close();
} catch (err) {
// Ignore for now...
}
};
//
// Helpers
//
/**
Routing table structure. An object with hostname as key, and an array as value.
The array has one element per path associated to the given hostname.
Every path has a Round-Robin value (rr) and urls array, with all the urls available
for this target route.
{
hostA :
[
{
path: '/',
rr: 3,
urls: []
}
]
}
*/
var respondNotFound = function (req, res) {
res.statusCode = 404;
res.write('Not Found');
res.end();
};
ReverseProxy.prototype.notFound = function (callback) {
if (typeof callback == "function")
respondNotFound = callback;
else
throw Error('notFound callback is not a function');
};
//
// Redirect to the HTTPS proxy
//
function redirectToHttps(req, res, target, ssl, log) {
req.url = req._url || req.url; // Get the original url since we are going to redirect.
var targetPort = ssl.redirectPort || ssl.port;
var hostname = req.headers.host.split(':')[0] + (targetPort ? ':' + targetPort : '');
var url = 'https://' + path.join(hostname, req.url);
log && log.info('Redirecting %s to %s', path.join(req.headers.host, req.url), url);
//
// We can use 301 for permanent redirect, but its bad for debugging, we may have it as
// a configurable option.
//
res.writeHead(302, { Location: url });
res.end();
}
function startsWith(input, str) {
return input.slice(0, str.length) === str &&
(input.length === str.length || input[str.length] === '/')
}
function prepareUrl(url) {
url = _.clone(url);
if (_.isString(url)) {
url = setHttp(url);
if (!validUrl.isHttpUri(url) && !validUrl.isHttpsUri(url)) {
throw Error('uri is not a valid http uri ' + url);
}
url = parseUrl(url);
}
return url;
}
function getCertData(pathname, unbundle) {
var fs = require('fs');
// TODO: Support input as Buffer, Stream or Pathname.
if (pathname) {
if (_.isArray(pathname)) {
var pathnames = pathname;
return _.flatten(_.map(pathnames, function (_pathname) {
return getCertData(_pathname, unbundle);
}));
} else if (fs.existsSync(pathname)) {
if (unbundle) {
return unbundleCert(fs.readFileSync(pathname, 'utf8'));
} else {
return fs.readFileSync(pathname, 'utf8');
}
}
}
}
/**
Unbundles a file composed of several certificates.
http://www.benjiegillam.com/2012/06/node-dot-js-ssl-certificate-chain/
*/
function unbundleCert(bundle) {
var chain = bundle.trim().split('\n');
var ca = [];
var cert = [];
for (var i = 0, len = chain.length; i < len; i++) {
var line = chain[i].trim();
if (!(line.length !== 0)) {
continue;
}
cert.push(line);
if (line.match(/-END CERTIFICATE-/)) {
var joined = cert.join('\n');
ca.push(joined);
cert = [];
}
}
return ca;
}
var tls = require('tls');
function createCredentialContext(key, cert, ca) {
var opts = {};
opts.key = getCertData(key);
opts.cert = getCertData(cert);
if (ca) {
opts.ca = getCertData(ca, true);
}
var credentials = tls.createSecureContext(opts);
return credentials.context;
}
//
// https://stackoverflow.com/questions/18052919/javascript-regular-expression-to-add-protocol-to-url-string/18053700#18053700
// Adds http protocol if non specified.
function setHttp(link) {
if (link.search(/^http[s]?\:\/\//) === -1) {
link = 'http://' + link;
}
return link;
}
module.exports = ReverseProxy;