FirstCommit
This commit is contained in:
162
lib/docker.js
Normal file
162
lib/docker.js
Normal 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
93
lib/etcd-backend.js
Normal 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
137
lib/letsencrypt.js
Normal 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
780
lib/proxy.js
Normal 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
109
lib/redis-backend.js
Normal 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;
|
||||
|
||||
|
Reference in New Issue
Block a user