flaks
Version:
reverse http/https proxy optimized for edge/outgoing traffic
387 lines (311 loc) • 12.6 kB
JavaScript
const http_proxy = require ('@pepmartinez/http-proxy');
const util = require ('util');
const _ = require ('lodash');
const Log = require ('winston-log-space');
const CBuffer = require ('CBuffer');
const log = Log.logger ('lib:http-proxy');
const wirelog = Log.logger ('wire:upstream');
function _do_wirelog_headers (req) {
let id = req.id || '-';
wirelog.info('%s > %s %s HTTP/1.1', id, req.method, req.path);
_.each (req._headers, (v, k) => wirelog.info('%s > %s: %s', id, k, v));
wirelog.info('%s >', id);
}
//////////////////////////////////////////////////////////
class HttpProxy {
//////////////////////////////////////////////////////////
constructor (opts, env, context) {
this._opts = opts;
this._env = env;
this._context = context;
this._proxy = http_proxy.createProxyServer (opts);
this._introspect_store = {};
this._err_responses = {
responses: {
ECONNRESET: {
code: 504,
ct: 'text/plain',
body: err => 'proxy timeout:\n' + util.inspect(err)
},
ECONNREFUSED: {
code: 503,
ct: 'text/plain',
body: err => 'upstream connection refused:\n' + util.inspect(err)
},
EPROTO: {
code: 503,
ct: 'text/plain',
body: err => 'upstream protocol error:\n' + util.inspect(err)
},
ENOTFOUND: {
code: 502,
ct: 'text/plain',
body: err => 'upstream name resolution error:\n' + util.inspect(err)
},
ERR: {
code: 500,
ct: 'text/plain',
body: err => 'proxy error:\n' + util.inspect(err)
}
}
};
this._proxy.on ('proxyReq', (proxyReq, req, res, options) => this._on_proxyReq (proxyReq, req, res, options));
this._proxy.on ('error', (err, req, res) => this._on_error (err, req, res));
log.verbose ('created for vhost [%s], with opts %j', env.vhost, opts);
}
//////////////////////////////////////////////////////////
init (cb) {
// prometheus metrics
if (this._context && this._context.promster) {
let the_metric = this._context.promster.register.getSingleMetric('http_upstream_request_duration');
if (the_metric) {
this._reqs_histogram = the_metric;
}
else {
this._reqs_histogram = new this._context.promster.Histogram({
name: 'http_upstream_request_duration',
help: 'histogram on duration for upstream http requests',
buckets: [ 50, 100, 300, 500, 800, 1000, 1500, 2000, 3000, 5000, 10000 ],
labelNames: ['method', 'statusCode', 'uri', 'route', 'vhost']
});
}
}
log.verbose ('initialized');
cb ();
}
//////////////////////////////////////////////////////////
_is_option_activated (id, opts, req) {
if (_.isBoolean (opts[id])) return opts[id];
if (_.isFunction (opts[id])) return opts[id](opts, req);
return false;
}
//////////////////////////////////////////////////////////
_to_introspect_store (obj) {
let k0 = obj.upstream.url;
let k1 = (obj.res || {}).st_code || (obj.err || {}).code || 'unknown';
if (!this._introspect_store[k0]) this._introspect_store[k0] = {};
if (!this._introspect_store[k0][k1]) this._introspect_store[k0][k1] = new CBuffer (16);
this._introspect_store[k0][k1].push (obj);
}
//////////////////////////////////////////////////////////
_on_proxyReq (proxyReq, req, res, options) {
proxyReq.id = req.id;
req._upstream_route = options.route;
log.verbose('proxyReq [%s] %s %s %s -> %s', proxyReq.id, req.method, req.hostname, req.path, options.target.href);
// cache opts activation
if (this._is_option_activated ('wirelog', options, req)) options._is_wirelog_activated = true;
if (this._is_option_activated ('introspect', options, req)) options._is_introspect_activated = true;
if (options._is_wirelog_activated || options._is_introspect_activated) {
this._instrument_upstream_wire (req, proxyReq, options);
}
// initialize introspect
if (options._is_introspect_activated) {
req._introspect = {
id: req.id,
route: options.route,
upstream: options.upstream,
req: {
t: new Date(),
method: proxyReq.method,
url: options.target.href,
headers: {},
body: ''
}
};
_.each (proxyReq.getHeaders(), (v, k) => req._introspect.req.headers[k] = v);
}
proxyReq.on ('response', rsp => {
log.verbose ('proxyRes [%s] %s %d', rsp.req.id, rsp.req.path, rsp.statusCode);
if (options._is_wirelog_activated || options._is_introspect_activated) {
if (options._is_wirelog_activated) {
wirelog.info ('%s < HTTP/%s %s %s', proxyReq.id, rsp.httpVersion, rsp.statusCode, rsp.statusMessage);
_.each (rsp.headers, (v, k) => wirelog.info ('%s < %s: %s', proxyReq.id, k, v));
wirelog.info ('%s <', proxyReq.id);
}
rsp.on ('data', chunk => {
if (options._is_wirelog_activated) {
chunk.toString ().split ('\n').forEach (l => wirelog.info ('%s < %s', proxyReq.id, l));
}
if (options._is_introspect_activated) {
req._introspect.res.body += chunk.toString ();
}
});
}
if (options._is_introspect_activated) {
req._introspect.res = {
t: new Date(),
ver: rsp.httpVersion,
st_code: rsp.statusCode,
st_msg: rsp.statusMessage,
headers: rsp.headers,
body: ''
};
}
rsp.on ('end', () => {
log.verbose ('proxyRes [%s] end', proxyReq.id);
var rtt = process.hrtime (rsp.req._t0);
req._upstream_rtt = (rtt[0] * 1000) + (rtt[1]/1000000);
this._upstream_tick (req, proxyReq, rsp, null, options);
if (options._is_introspect_activated) {
this._to_introspect_store (req._introspect);
}
});
rsp.on ('error', err => {
log.verbose ('proxyRes [%s] err %j', proxyReq.id, err);
var rtt = process.hrtime (rsp.req._t0);
req._upstream_rtt = (rtt[0] * 1000) + (rtt[1]/1000000);
this._upstream_tick (req, proxyReq, rsp, null, options);
if (options._is_introspect_activated) {
req._introspect.err = err;
this._to_introspect_store (req._introspect);
}
});
});
proxyReq.on ('finish', () => {
log.verbose ('proxyReq [%s] finished sent', proxyReq.id);
});
// set a connection timeout if socket not reused/connected
if (proxyReq.socket.pending) {
var t = setTimeout(() => {
log.info ('proxyReq [%s] connect to upstream [%s] timed out (%d msecs)', proxyReq.id, options.target.href, options.connectTimeout);
proxyReq.socket.destroy();
}, options.connectTimeout);
proxyReq.socket.once ('connect', () => clearTimeout(t));
proxyReq.socket.once ('close', () => clearTimeout(t));
}
proxyReq.on ('error', err => {
var rtt = process.hrtime (proxyReq._t0);
req._upstream_rtt = (rtt[0] * 1000) + (rtt[1]/1000000);
this._upstream_tick (req, proxyReq, null, err, options);
if (options._is_introspect_activated) {
req._introspect.err = err;
this._to_introspect_store (req._introspect);
}
if (!(res.headersSent)) {
if (proxyReq.writableFinished) {
log.error('proxy upstream error: [%s] %j (res headers NOT sent, proxyReq flushed): respond 502', req.id, err);
res.writeHead (502, {'Content-Type': 'text/plain'});
res.end ('request proxied to upstream but that did not respond:\n' + util.inspect(err));
}
else {
log.error('proxy upstream error: [%s] %j (res headers NOT sent, proxyReq NOT flushed): try next upstream', req.id, err);
// try next target
this._try_web (req, res);
}
}
else {
log.error ('proxy upstream error: [%s] %j (res headers sent): do nothing', req.id, err, res.headersSent);
}
});
proxyReq._t0 = process.hrtime();
}
//////////////////////////////////////////////////////////
_on_error (err, req, res) {
return log.error('proxy error: [%s] %j', req.id, err);
}
//////////////////////////////////////////////////////////
web (req, res, opts) {
// add opts.targets as array of targets from opts.target
req._upstream_targets = opts.target;
req._upstream = _.clone (opts.upstream);
req._upstream_opts = opts;
log.verbose ('[%s] targets is %o', req.id, req._upstream_targets);
this._try_web (req, res);
}
//////////////////////////////////////////////////////////
_try_web (req, res) {
for (;;) {
if (req._upstream_targets.length == 0) {
// no more upsteams, relay last error
log.error('[%s] No more upstreams. Sending 502 response', req.id);
// TODO add info depending on proxyReq.writableFinished
return res.status (502).send ('no upstreams available');
}
// call this._proxy.web with first target
const target = req._upstream_targets.shift ();
const upstream = req._upstream.shift ();
if ((upstream.check) && (!upstream.check.ok)) {
log.verbose ('[%s] skip target %s (upstream %s, DOWN)', req.id, target, upstream.url);
}
else {
req._upstream_opts.target = target;
req._upstream_opts.upstream = upstream;
log.verbose ('[%s] try target %s (upstream %s)', req.id, target, upstream.url);
return this._proxy.web (req, res, req._upstream_opts);
}
}
}
//////////////////////////////////////////////////////////
close () {
this._proxy.close ();
}
//////////////////////////////////////////////////////////
status () {
return _.mapValues (this._introspect_store, (v0 => _.mapValues(v0, v1 => v1.toArray())));
// TODO add more info here
}
//////////////////////////////////////////////////////////
_server_req_as_human (req) {
return `${req.method} ${req.hostname} ${req.path}`;
}
//////////////////////////////////////////////////////////
_instrument_upstream_wire (req, proxyReq, options) {
if (options._is_wirelog_activated) {
proxyReq._instrument_wire_headers_dumped = false;
}
if (!proxyReq._instrument_wire_write) {
proxyReq._instrument_wire_write = proxyReq.write;
// hijack write()
proxyReq.write = function (data, encoding, callback) {
if (options._is_wirelog_activated) {
if (!this._instrument_wire_headers_dumped) {
_do_wirelog_headers(this);
this._instrument_wire_headers_dumped = true;
}
}
if (options._is_wirelog_activated) {
data.toString(encoding).split ('\n').forEach (l => wirelog.info ('%s > %s', proxyReq.id, l));
}
if (options._is_introspect_activated) {
req._introspect.req.body += data.toString (encoding);
}
return this._instrument_wire_write (data, encoding, callback);
};
}
// hijack end()
if (!proxyReq._instrument_wire_end) {
proxyReq._instrument_wire_end = proxyReq.end;
proxyReq.end = function (data, encoding, callback) {
if (options._is_wirelog_activated) {
if (!this._instrument_wire_headers_dumped) {
_do_wirelog_headers (this);
this._instrument_wire_headers_dumped = true;
}
}
return this._instrument_wire_end (data, encoding, callback);
};
}
}
//////////////////////////////////////////////////////////
_upstream_tick (req, proxyReq, rsp, err, options) {
if (this._reqs_histogram) {
let labels = {
route: options.route,
uri: options.upstream.url,
method: proxyReq.method,
statusCode: (rsp && rsp.statusCode) || (err && err.code),
vhost: this._env.vhost
};
// log.debug ('tick %j %d', labels, req._upstream_rtt);
this._reqs_histogram.observe(labels, req._upstream_rtt);
}
// pass upstream info to req, for mw use
req._upstream_route = options.route;
req._upstream_uri_grp = options.upstream;
req._upstream_uri = options.target.href;
req._upstream_method = proxyReq.method;
req._upstream_statusCode = (rsp && rsp.statusCode) || (err && err.code);
req._upstream_vhost = this._env.vhost;
}
}
module.exports = HttpProxy;