flaks
Version:
reverse http/https proxy optimized for edge/outgoing traffic
424 lines (331 loc) • 11.6 kB
JavaScript
const _ = require ('lodash');
const Log = require ('winston-log-space');
const intoStream = require ('into-stream');
const get = require ('simple-get');
const async = require ('async');
const Chance = require ('chance');
var HttpProxy = require ('./HttpProxy');
const chance = new Chance();
//////////////////////////////////////////////////////////
class VHost {
//////////////////////////////////////////////////////////
constructor (id, config, context, full_config) {
this._id = id;
this._config = config;
this._full_config = full_config;
this._context = context;
this._log = Log.logger (`lib:vhost:${id}`);
this._http_proxy = new HttpProxy ({
xfwd: true,
ignorePath: true,
changeOrigin: true,
timeout: _.get (config, 'net.incoming_timeout', _.get (full_config, 'net.incoming_timeout', 75000)),
proxyTimeout: _.get (config, 'net.outgoing_timeout', _.get (full_config, 'net.outgoing_timeout', 50000)),
connectTimeout: _.get (config, 'net.connect_timeout', _.get (full_config, 'net.connect_timeout', 5000)),
wirelog: _.get (config, 'http.wirelog', _.get (full_config, 'http.wirelog', false)),
introspect: _.get (config, 'http.introspect', _.get (full_config, 'http.introspect', false)),
}, {
vhost: this._id
},
context);
this._log.verbose ('created');
}
//////////////////////////////////////////////////////////
_init_metrics () {
// prometheus metrics
if (this._context && this._context.promster) {
let the_metric = this._context.promster.register.getSingleMetric('http_upstream_up');
if (the_metric) {
this._upstreams_up = the_metric;
}
else {
this._upstreams_up = new this._context.promster.Gauge({
name: 'http_upstream_up',
help: 'healthy state upstream http requests',
labelNames: ['target', 'vhost']
});
}
}
this._log.verbose ('initialized');
}
//////////////////////////////////////////////////////////
_set_active_check (target, route_name) {
const check_path = _.get (target, 'check.path');
const check_port = _.get (target, 'check.port');
if (check_path) {
const as_url = new URL (target.url);
as_url.pathname = check_path;
if (check_port) as_url.port = +check_port;
target.check.url = as_url.href;
target.check.ok = false;
this._log.verbose ('setting check [%s] on %s (route %s)', target.check.url, target.url, route_name);
this._target_checks.push (target);
}
}
//////////////////////////////////////////////////////////
_set_agent (route) {
let proto = route.target[0].url.split (':')[0];
switch (proto) {
case 'http':
this._log.verbose (' using http agent %s', route.agent);
route.agent = this._agents.get_http (route.agent);
route.proto = proto;
break;
case 'https':
this._log.verbose (' using https agent %s', route.agent);
route.agent = this._agents.get_https (route.agent);
route.proto = proto;
break;
default:
this._log.error ('unknown protocol [%s] on route %j', proto, route);
return false;
}
return true;
}
//////////////////////////////////////////////////////////
_set_lb (route, route_name) {
switch (route.lb) {
case 'seq':
route.lb_method = VHost.s_lb_method_seq;
break;
case 'rand':
route.lb_method = VHost.s_lb_method_rand;
break;
case undefined:
case null:
route.lb_method = VHost.s_lb_method_rand;
break;
default:
this._log.warn ('unknown lb method [%s] on route %s. Using "seq"', route.lb, route_name);
route.lb_method = VHost.s_lb_method_seq;
break;
}
}
//////////////////////////////////////////////////////////
init (cb) {
this._init_metrics ();
// init agents--route mapping
this._agents = this._context.agents;
this._path_idx = [];
this._paths = {};
this._target_checks = [];
_.each (_.get (this._config, 'http.routes', {}), (v, k) => {
this._log.debug ('got route %s', k);
this._path_idx.push (k);
this._paths[k] = _.merge ({}, v, {regex: k.startsWith ('^') ? new RegExp (k) : new RegExp ('^' + k)});
const route = this._paths[k];
if (! _.isArray (route.target)) route.target = [route.target];
// expand string targets
route.target = route.target.map (t => ( _.isString (t) ? {url: t} : t ) );
// sanitize and register active checks
route.target.forEach (t => {
this._set_active_check (t, k);
if (! _.isInteger (t.w)) t.w = 1;
});
// resolve agent
this._set_agent (route);
// set lb method
this._set_lb (route, k);
});
this._path_idx.sort((a, b) => b.length - a.length);
// run a round of upstream checks to start in a known status
// and init proxy
async.series ([
cb => this._http_proxy.init (cb),
cb => this._ensure_checks_done_and_armed (cb)
], err => {
if (err) return cb (err);
this._log.verbose ('initialized');
cb ();
});
}
//////////////////////////////////////////////////////////
close () {
// stop checks
this._target_checks.forEach (t => {
if (t.check.timer) {
clearTimeout (t.check.timer);
t.check.timer = undefined;
this._log.verbose ('stopping check [%s] on %s', t.check.url, t.url);
}
});
this._http_proxy.close ();
this._log.verbose ('closed');
}
//////////////////////////////////////////////////////////
status () {
return this._http_proxy.status ();
// TODO add more info here...
}
//////////////////////////////////////////////////////////
static s_lb_method_seq (upstreams) {
return upstreams;
}
//////////////////////////////////////////////////////////
static s_lb_method_rand (upstreams) {
const ret = VHost._sort_by_weight (upstreams);
return ret;
}
//////////////////////////////////////////////////////////
static s_get_target (req, mtch) {
// apply lb-strategy by sorting-shuffling arrays
const upstream = mtch.lb_method (mtch.target);
let target;
const idx = req.originalUrl.indexOf('?');
if (idx == -1) {
// no orig qstring
target = upstream.map (t => req.path.replace (mtch.regex, t.url));
}
else {
// has orig qstring
const qstr = req.originalUrl.substr (idx + 1);
target = upstream.map (t =>
req.path.replace (mtch.regex, t.url) + ((t.url.indexOf ('?') == -1) ? ('?' + qstr ) : ('&' + qstr))
);
}
return {target, upstream};
}
//////////////////////////////////////////////////////////
serve (req, res) {
let mtch = null;
let mtch_id = null;
// linear search (pre-ordered) of longest match
for (let i = 0; ((!mtch) && (i < this._path_idx.length)); i++) {
let _match = this._paths[this._path_idx[i]];
if (_match.regex.test (req.path)) {
mtch = _match;
mtch_id = this._path_idx[i];
this._log.debug ('check %s against %s, match is %j', req.path, _match.regex, _match.target);
}
else {
this._log.debug ('check %s against %s, miss', req.path, _match.regex);
}
}
if (!mtch) return res.status(404).send ('not found');
// actual proxying
const {target, upstream} = VHost.s_get_target (req, mtch);
const opts = {
headers: {'X-Request-Id': req.id},
agent: mtch.agent,
secure: mtch.secure,
route: mtch_id,
upstream: upstream,
target: target
};
const body = req.text || req.body;
if (body) {
if (_.isString (body)) {
opts.buffer = intoStream (body);
this._log.debug ('found a pre-read string body, forward it');
}
else if (_.isObject (body) || _.isArray (body)) {
delete req.headers['content-length'];
opts.buffer = intoStream (JSON.stringify (body));
this._log.debug ('found a pre-read object body, forward it');
}
}
this._http_proxy.web (req, res, opts);
}
//////////////////////////////////////////////////////////////
_enable_check_target (target) {
if (target.check.timer) return;
target.check.timer = setTimeout (() => this._check_target (target), 5000);
}
//////////////////////////////////////////////////////////////
_ensure_checks_done_and_armed (cb) {
const tasks = [];
this._target_checks.forEach (t => tasks.push (cb => this._check_target (t, cb)));
async.parallelLimit (tasks, 32, cb);
}
//////////////////////////////////////////////////////////////
_check_target_ko (target, err, cb) {
this._upstreams_up.set ({
target: target.url,
vhost: this._id
}, 0);
if (target.check.ok) {
this._log.info ('check upstream [%s] on %s failed (%s), disabling upstream', target.url, target.check.url, VHost.stringify_err(err));
target.check.ok = false;
}
else {
this._log.debug ('check upstream [%s] on %s still failing (%s)', target.url, target.check.url, VHost.stringify_err(err));
}
this._enable_check_target (target);
if (cb) cb (null, err);
}
//////////////////////////////////////////////////////////////
_check_target_ok (target, cb) {
this._upstreams_up.set ({
target: target.url,
vhost: this._id
}, 1);
if (!target.check.ok) {
this._log.info ('check upstream [%s] on %s succeeded, enabling upstream', target.url, target.check.url);
target.check.ok = true;
}
else {
this._log.debug ('check upstream [%s] on %s still ok', target.url, target.check.url);
}
this._enable_check_target (target);
if (cb) cb ();
}
//////////////////////////////////////////////////////////////
_check_target (target, cb) {
target.check.timer = undefined;
this._log.debug ('checking upstream [%s] on %s...', target.url, target.check.url);
const opts = {
url: target.check.url,
timeout: 1000
};
get.concat (opts, (err, res, data) => {
if (err) return this._check_target_ko (target, err, cb);
if (+(res.statusCode) != 200) return this._check_target_ko (target, {statusCode: res.statusCode, message: `got a HTTP ${res.statusCode} response`}, cb);
this._check_target_ok (target, cb);
});
}
//////////////////////////////////////////////////////////////
static _sort_by_weight (a) {
let grid = [];
let total_w = 0;
let grid_len = 0;
a.forEach (i => {
if (i.w > 0) {
grid.push (i);
total_w += i.w;
grid_len++;
}
});
const result = [];
for (;;) {
if (grid_len == 0) {
return result;
}
const dice = chance.integer({ min: 1, max: total_w });
for (let c = 0, i = 0; i < grid.length; i++) {
if (grid[i] == null) continue;
c += grid[i].w;
if (c >= dice) {
const elem = grid[i];
grid[i] = null;
grid_len--;
total_w -= elem.w;
result.push (elem);
break;
}
}
}
}
/////////////////////////////////////
static stringify_err (err) {
if (_.isString (err)) return err;
if (err.message) return err.message;
if (err.stack) return err.stack;
try {
return JSON.stringify (err);
}
catch (e) {
return err;
}
}
}
module.exports = VHost;