FirstCommit

This commit is contained in:
Fabian Stamm
2018-06-29 15:47:19 +02:00
commit 74492e3f6b
52 changed files with 3765 additions and 0 deletions

162
lib/docker.js Normal file
View File

@ -0,0 +1,162 @@
/*eslint-env node */
'use strict';
/**
Redbird Docker Module.
This module handles automatic regitration and de-registration of
services running on docker containers.
*/
var Dolphin = require('dolphin');
function DockerModule(redbird, url) {
if (!(this instanceof DockerModule)) {
//TODO: should it return a new instance per redbird proxy?
//because every time we run -> docker(redbird).register("localhost", "tomcat*")
//a new DockerModule is created
return new DockerModule(redbird, url);
}
this.redbird = redbird;
var log = redbird.log;
var targets = this.targets = {};
this.ports = {};
// We keep an up-to-date table with all the images having
// containers running on the system.
var images = this.images = {};
var dolphin = this.dolphin = new Dolphin(url);
var _this = this;
function registerIfNeeded(imageName, containerId, containerName) {
var image = images[imageName] = images[imageName] || {};
for (var targetName in targets) {
var match = isMatchingImageName(targetName, imageName);
if (match && image[containerId] !== 'running') {
var target = targets[targetName];
log && log.info('Registering container %s for target %s', containerName, target.src);
_this.registerContainer(target.src, containerId, target.opts);
}
}
image[containerId] = 'running';
}
// Start docker event listener
this.events = dolphin.events();
this.events.on('connected', function () {
// Fetch all running containers and register them if
// necessary.
dolphin.containers({ filters: {status:["running"]}}).then(function (containers) {
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
registerIfNeeded(container.Image, container.Id, container.Names[0].replace("/", ""));
}
});
});
this.events.on('event', function (evt) {
var image, target;
log && log.info('Container %s changed to status %s', evt.Actor.Attributes.name, evt.status);
switch (evt.status) {
case 'start':
case 'restart':
case 'unpause':
registerIfNeeded(evt.from, evt.id, evt.Actor.Attributes.name);
break;
case 'stop':
case 'die':
case 'pause':
image = images[evt.from];
if (image) {
for (var targetName in targets) {
var match = isMatchingImageName(targetName, evt.from);
if (image[evt.id] === 'running' && match && _this.ports[evt.id]) {
target = targets[targetName];
log && log.info('Un-registering container %s for target %s', evt.Actor.Attributes.name, target.src);
_this.redbird.unregister(target.src, _this.ports[evt.id]);
}
image[evt.id] = 'stopped';
}
}
break;
default:
// Nothing
}
});
this.events.on('error', function (err) {
log && log.error(err, 'dolphin docker event error');
});
}
/**
* Register route from a source to a given target.
* The target should be an image name. Starting several containers
* from the same image will automatically deliver the requests
* to each container in a round-robin fashion.
*
* @param src See {@link ReverseProxy.register}
* @param target Docker image (this string is evaluated as regexExp)
* @param opts Options like ssl and etc...
*/
DockerModule.prototype.register = function (src, target, opts) {
var storedTarget = this.targets[target];
if (storedTarget && storedTarget.src == src) {
throw Error('Cannot register the same src and target twice');
}
this.targets[target] = {
src: src,
opts: opts
};
for (var imageName in this.images) {
var image = images[imageName];
for (var containerId in image) {
//TODO: Changed registerIfNeeded to be reusable here
registerIfNeeded(imageName, containerId, containerId);
}
}
};
DockerModule.prototype.registerContainer = function (src, containerId, opts) {
var _this = this;
containerPort(this.dolphin, containerId).then(function (targetPort) {
_this.redbird.register(src, targetPort, opts);
_this.ports[containerId] = targetPort;
});
};
function isMatchingImageName(targetName, imageName) {
var regex = new RegExp("^" + targetName + "$");
return regex.test(imageName);
}
function containerPort(dolphin, containerId) {
return dolphin.containers.inspect(containerId).then(function (container) {
var port = Object.keys(container.NetworkSettings.Ports)[0].split('/')[0];
var netNames = Object.keys(container.NetworkSettings.Networks);
if (netNames.length === 1) {
var ip = container.NetworkSettings.Networks[netNames[0]].IPAddress;
if (port && ip) {
return 'http://' + ip + ':' + port;
}
} else {
//TODO: Implements opts for manually choosing the network/ip/port...
}
throw Error('No valid address or port ' + container.IPAddress + ':' + port);
});
}
module.exports = DockerModule;

93
lib/etcd-backend.js Normal file
View File

@ -0,0 +1,93 @@
/*eslint-env node */
'use strict';
/**
Redbird ETCD Module
This module handles automatic proxy registration via etcd
*/
var Etcd = require('node-etcd');
function ETCDModule(redbird, options){
if (!(this instanceof ETCDModule)){
return new ETCDModule(redbird, options);
}
// Create Redbird Instance and Log
this.redbird = redbird;
var log = redbird.log;
var _this = this;
// Create node-etcd Instance
this.etcd = new Etcd(options.hosts,options.ssloptions);
this.etcd_dir = (typeof options.path !== 'undefined') ? options.path : "redbird";
// Create directory if not created
this.etcd.get(this.etcd_dir,function(err, body, header){
if (err && err.errorCode == 100){
_this.etcd.mkdir(_this.etcd_dir, function(err){
if (err){
log.error(err, 'etcd backend error');
}
else{
createWatcher();
}
});
}
else if(!err && body.node.dir){
createWatcher();
}
else{
log.error(err, 'etcd backend error');
}
});
// Helper function to check if values contain settings
function IsJsonString(str) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
// Helper function to pretify etcd directory strings
function removeEtcDir(str) {
return str.replace(_this.etcd_dir, '').replace(/^\/+|\/+$/g, '');
}
function createWatcher(){
// Watch etcd directory
_this.watcher = _this.etcd.watcher(_this.etcd_dir, null, {recursive:true});
// On Add/Update
_this.watcher.on("change", function(body,headers){
if(body.node.key && body.node.value && !IsJsonString(body.node.value)){
_this.redbird.register(removeEtcDir(body.node.key),body.node.value);
}
else if(body.node.key && body.node.value && IsJsonString(body.node.value)){
var config = JSON.parse(body.node.value);
if (typeof config.docker !== 'undefined'){
require('../').docker(_this.redbird).register(body.node.key,body.node.value.docker,body.node.value);
}
else {
_this.redbird.register(removeEtcDir(body.node.key),config.hosts,config);
}
}
});
// On Delete
_this.watcher.on("delete", function(body,headers){
if(body.node.key){
_this.redbird.unregister(removeEtcDir(body.node.key));
}
});
// Handle Errors
_this.watcher.on("error", function(err){
log.error(err, 'etcd backend error');
});
}
}
module.exports = ETCDModule;

137
lib/letsencrypt.js Normal file
View File

@ -0,0 +1,137 @@
/**
* Letsecript module for Redbird (c) Optimalbits 2016
*
*
*
*/
var letsencrypt = require('letsencrypt');
/**
* LetsEncrypt certificates are stored like the following:
*
* /example.com
* /
*
*
*
*/
var leStoreConfig = {};
var webrootPath = ':configDir/:hostname/.well-known/acme-challenge';
function init(certPath, port, logger){
var http = require('http');
var path = require('path');
var url = require('url');
var fs = require('fs');
logger && logger.info('Initializing letsencrypt, path %s, port: %s', certPath, port);
leStoreConfig = {
configDir: certPath,
privkeyPath: ':configDir/:hostname/privkey.pem',
fullchainPath: ':configDir/:hostname/fullchain.pem',
certPath: ':configDir/:hostname/cert.pem',
chainPath: ':configDir/:hostname/chain.pem',
workDir: ':configDir/letsencrypt/var/lib',
logsDir: ':configDir/letsencrypt/var/log',
webrootPath: webrootPath,
debug: false
};
// we need to proxy for example: 'example.com/.well-known/acme-challenge' -> 'localhost:port/example.com/'
http.createServer(function (req, res){
var uri = url.parse(req.url).pathname;
var filename = path.join(certPath, uri);
var isForbiddenPath = uri.length < 3 || filename.indexOf(certPath) !== 0;
if (isForbiddenPath) {
logger && logger.info('Forbidden request on LetsEncrypt port %s: %s', port, filename);
res.writeHead(403);
res.end();
return;
}
logger && logger.info('LetsEncrypt CA trying to validate challenge %s', filename);
fs.stat(filename, function(err, stats) {
if (err || !stats.isFile()) {
res.writeHead(404, {"Content-Type": "text/plain"});
res.write("404 Not Found\n");
res.end();
return;
}
res.writeHead(200);
fs.createReadStream(filename, "binary").pipe(res);
});
}).listen(port);
}
/**
* Gets the certificates for the given domain.
* Handles all the LetsEncrypt protocol. Uses
* existing certificates if any, or negotiates a new one.
* Returns a promise that resolves to an object with the certificates.
* TODO: We should use something like https://github.com/PaquitoSoft/memored/blob/master/index.js
* to avoid
*/
function getCertificates(domain, email, production, renew, logger){
var LE = require('letsencrypt');
var le;
// Storage Backend
var leStore = require('le-store-certbot').create(leStoreConfig);
// ACME Challenge Handlers
var leChallenge = require('le-challenge-fs').create({
webrootPath: webrootPath,
debug: false
});
le = LE.create({
server: production ? LE.productionServerUrl : LE.stagingServerUrl,
store: leStore, // handles saving of config, accounts, and certificates
challenges: { 'http-01': leChallenge }, // handles /.well-known/acme-challege keys and tokens
challengeType: 'http-01', // default to this challenge type
debug: false,
log: function (debug) {
logger && logger.info(arguments, 'Lets encrypt debugger');
}
});
// Check in-memory cache of certificates for the named domain
return le.check({ domains: [domain] }).then(function (cert){
var opts = {
domains: [domain],
email: email,
agreeTos: true,
rsaKeySize: 2048, // 2048 or higher
challengeType: 'http-01'
}
if (cert){
if (renew){
logger && logger.info('renewing cert for ' + domain);
opts.duplicate = true;
return le.renew(opts, cert).catch(function(err){
logger && logger.error(err, 'Error renewing certificates for ', domain);
});
} else {
logger && logger.info('Using cached cert for ' + domain);
return cert;
}
} else {
// Register Certificate manually
logger && logger.info('Manually registering certificate for %s', domain);
return le.register(opts).catch(function (err) {
logger && logger.error(err, 'Error registering LetsEncrypt certificates');
});
}
});
}
module.exports.init = init;
module.exports.getCertificates = getCertificates;

780
lib/proxy.js Normal file
View File

@ -0,0 +1,780 @@
/*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;

109
lib/redis-backend.js Normal file
View File

@ -0,0 +1,109 @@
"use strict";
var redis = require('redis');
var Promise = require('bluebird');
var _ = require('lodash');
Promise.promisifyAll(redis);
/**
Instantiates a Redis Redbird backend.
opts: {
prefix: '',
port: 6739,
host: 'localhost',
opts: {}
}
*/
function RedisBackend(port, hostname, opts)
{
if(!(this instanceof RedisBackend)){
return new RedisBackend(port, hostname, opts);
}
opts = opts || {};
port = port || 6379;
hostname = hostname || 'localhost';
this.redis = redis.createClient(port, hostname, opts);
this.publish = redis.createClient(port, hostname, opts);
this.prefix = opts.prefix + '';
this.baseKey = baseKey(this.prefix);
}
/**
Returns a Promise that resolves to an array with all the
registered services and removes the expired ones.
*/
RedisBackend.prototype.getServices = function(){
var _this = this;
var redis = this.redis;
var baseKey = this.baseKey;
//
// Get all members in the service set.
//
return redis.smembersAsync(baseKey + 'ids').then(function(serviceIds){
return Promise.all(_.map(serviceIds, function(id){
return _this.getService(id);
}));
}).then(function(services){
// Clean expired services
return _.compact(services);
});
}
RedisBackend.prototype.getService = function(id){
var redis = this.redis;
//
// Get service hash
//
return redis.hgetallAsync(this.baseKey + id).then(function(service){
if(service){
return service;
}else{
//
// Service has expired, we must delete it from the service set.
//
return redis.sremAsync(id);
}
});
}
RedisBackend.prototype.register = function(service){
var redis = this.redis;
var publish = this.publish;
var baseKey = this.baseKey;
//
// Get unique service ID.
//
return redis.incrAsync(baseKey + 'counter').then(function(id){
// Store it
redis.hset(baseKey + id, service).then(function(){
return id;
})
}).then(function(id){
//
// // Publish a meesage so that the proxy can react faster to a new registration.
//
return publish.publishAsync(baseKey + 'registered', id).then(function(){
return id;
})
});
}
RedisBackend.prototype.ping = function(id){
return this.redis.pexpireAsync(id, 5000);
}
function baseKey(prefix){
return 'redbird-' + prefix + '-services-';
}
module.exports = RedisBackend;