UNPKG

monkey-proxy

Version:

fork of tootallnate's proxy, adding hooks for modifying requests

554 lines (474 loc) 16.4 kB
/** * Module dependencies. */ var async = require('async'); var chalk = require('chalk'); var net = require('net'); var url = require('url'); var http = require('http'); var assert = require('assert'); var debug = require('debug')('proxy'); var once = require('once') var options = {}; // log levels debug.request = require('debug')('proxy ← ← ←'); debug.response = require('debug')('proxy → → →'); debug.proxyRequest = require('debug')('proxy ↑ ↑ ↑'); debug.proxyResponse = require('debug')('proxy ↓ ↓ ↓'); // hostname var hostname = require('os').hostname(); // proxy server version var version = require('./package.json').version; /** * Module exports. */ module.exports = setup; /** * Sets up an `http.Server` or `https.Server` instance with the necessary * "request" and "connect" event listeners in order to make the server act as an * HTTP proxy. * * @param {http.Server|https.Server} server * @param {Object} options * @api public */ function setup (server, options) { if (!options) options = {}; if (!server) server = http.createServer(); // the # of concurrent connections can be varied. var requestQueue = async.queue(function (task, done) { connectTask(task, requestQueue, server, options, done); }, options.concurrency || 1000); var connectQueue = async.queue(function (task, done) { connectTask(task, connectQueue, server, options, done); }, options.concurrency || 1000); server.on('request', function(req, res) { req.pause(); requestQueue.push({ req: req, res: res }); }); server.on('connect', function(req, socket, head) { req.pause(); socket.pause(); connectQueue.push({ req: req, socket: socket, head: head }) }); return server; } function connectTask(task, queue, server, options, done) { var socket = task.socket || task.req.socket; task.req.resume(); if (task.socket) task.socket.resume(); console.log('proxy connection to: ' + chalk.green(task.req.url) + ' (connection backlog = ' + queue.length() + ')'); if (task.socket) { onconnect.call(server, task.req, task.socket, task.head, options, once(function() { console.log('end connection: ' + chalk.red(task.req.url) + ' (connection backlog = ' + queue.length() + ')'); if (options.sleep) { console.log(chalk.yellow('sleeping ' + options.sleep + ' (ms)')); return setTimeout(done, options.sleep); } else return done(); })); } else { onrequest.call(server, task.req, task.res, options, once(function() { console.log('end connection: ' + chalk.red(task.req.url) + ' (connection backlog = ' + queue.length() + ')'); if (options.sleep) { console.log(chalk.yellow('sleeping ' + options.sleep + ' (ms)')); return setTimeout(done, options.sleep); } else return done(); })); } } /** * 13.5.1 End-to-end and Hop-by-hop Headers * * Hop-by-hop headers must be removed by the proxy before passing it on to the * next endpoint. Per-request basis hop-by-hop headers MUST be listed in a * Connection header, (section 14.10) to be introduced into HTTP/1.1 (or later). */ var hopByHopHeaders = [ 'Connection', 'Keep-Alive', 'Proxy-Authenticate', 'Proxy-Authorization', 'TE', 'Trailers', 'Transfer-Encoding', 'Upgrade' ]; // create a case-insensitive RegExp to match "hop by hop" headers var isHopByHop = new RegExp('^(' + hopByHopHeaders.join('|') + ')$', 'i'); /** * Iterator function for the request/response's "headers". * Invokes `fn` for "each" header entry in the request. * * @api private */ function eachHeader (obj, fn) { if (Array.isArray(obj.rawHeaders)) { // ideal scenario... >= node v0.11.x // every even entry is a "key", every odd entry is a "value" var key = null; obj.rawHeaders.forEach(function (v) { if (key === null) { key = v; } else { fn(key, v); key = null; } }); } else { // otherwise we can *only* proxy the header names as lowercase'd var headers = obj.headers; if (!headers) return; Object.keys(headers).forEach(function (key) { var value = headers[key]; if (Array.isArray(value)) { // set-cookie value.forEach(function (val) { fn(key, val); }); } else { fn(key, value); } }); } } /** * HTTP GET/POST/DELETE/PUT, etc. proxy requests. */ function onrequest (req, res, options, cb) { debug.request('%s %s HTTP/%s ', req.method, req.url, req.httpVersion); var server = this; var socket = req.socket; // pause the socket during authentication so no data is lost socket.pause(); authenticate(server, req, function (err, auth) { socket.resume(); if (err) { // an error occured during login! res.writeHead(500); res.end((err.stack || err.message || err) + '\n'); return; } if (!auth) return requestAuthorization(req, res); var parsed = url.parse(req.url); // proxy the request HTTP method parsed.method = req.method; // setup outbound proxy request HTTP headers var headers = {}; var hasXForwardedFor = false; var hasVia = false; var via = '1.1 ' + hostname + ' (proxy/' + version + ')'; parsed.headers = headers; eachHeader(req, function (key, value) { debug.request('Request Header: "%s: %s"', key, value); var keyLower = key.toLowerCase(); if (!hasXForwardedFor && 'x-forwarded-for' === keyLower) { // append to existing "X-Forwarded-For" header // http://en.wikipedia.org/wiki/X-Forwarded-For hasXForwardedFor = true; value += ', ' + socket.remoteAddress; debug.proxyRequest('appending to existing "%s" header: "%s"', key, value); } if (!hasVia && 'via' === keyLower) { // append to existing "Via" header hasVia = true; value += ', ' + via; debug.proxyRequest('appending to existing "%s" header: "%s"', key, value); } if (isHopByHop.test(key)) { debug.proxyRequest('ignoring hop-by-hop header "%s"', key); } else { var v = headers[key]; if (Array.isArray(v)) { v.push(value); } else if (null != v) { headers[key] = [ v, value ]; } else { headers[key] = value; } } }); // add "X-Forwarded-For" header if it's still not here by now // http://en.wikipedia.org/wiki/X-Forwarded-For if (!hasXForwardedFor) { headers['X-Forwarded-For'] = socket.remoteAddress; debug.proxyRequest('adding new "X-Forwarded-For" header: "%s"', headers['X-Forwarded-For']); } // add "Via" header if still not set by now if (!hasVia) { headers.Via = via; debug.proxyRequest('adding new "Via" header: "%s"', headers.Via); } if (options.close) { console.log(chalk.yellow("setting 'connection: close' header.")); headers.connection = 'close'; } // custom `http.Agent` support, set `server.agent` var agent = server.agent; if (null != agent) { debug.proxyRequest('setting custom `http.Agent` option for proxy request: %s', agent); parsed.agent = agent; agent = null; } if (null == parsed.port) { // default the port number if not specified, for >= node v0.11.6... // https://github.com/joyent/node/issues/6199 parsed.port = 80; } if ('http:' != parsed.protocol) { // only "http://" is supported, "https://" should use CONNECT method res.writeHead(400); res.end('Only "http:" protocol prefix is supported\n'); return; } var gotResponse = false; var proxyReq = http.request(parsed); debug.proxyRequest('%s %s HTTP/1.1 ', proxyReq.method, proxyReq.path); proxyReq.on('response', function (proxyRes) { debug.proxyResponse('HTTP/1.1 %s', proxyRes.statusCode); gotResponse = true; var headers = {}; eachHeader(proxyRes, function (key, value) { debug.proxyResponse('Proxy Response Header: "%s: %s"', key, value); if (isHopByHop.test(key)) { debug.response('ignoring hop-by-hop header "%s"', key); } else { var v = headers[key]; if (Array.isArray(v)) { v.push(value); } else if (null != v) { headers[key] = [ v, value ]; } else { headers[key] = value; } } }); debug.response('HTTP/1.1 %s', proxyRes.statusCode); res.writeHead(proxyRes.statusCode, headers); if (Array.isArray(options.transformResponse)) { options.transformResponse.forEach(function(transform) { proxyRes = proxyRes.pipe(transform()); }); } else if (options.transformResponse) proxyRes = proxyRes.pipe(options.transformResponse()); proxyRes.pipe(res); res.on('finish', onfinish); }); proxyReq.on('error', function (err) { debug.proxyResponse('proxy HTTP request "error" event\n%s', err.stack || err); cleanup(); if (gotResponse) { debug.response('already sent a response, just destroying the socket...'); socket.destroy(); } else if ('ENOTFOUND' == err.code) { debug.response('HTTP/1.1 404 Not Found'); res.writeHead(404); res.end(); } else { debug.response('HTTP/1.1 500 Internal Server Error'); res.writeHead(500); res.end(); } }); // if the client closes the connection prematurely, // then close the upstream socket function onclose () { debug.request('client socket "close" event, aborting HTTP request to "%s"', req.url); proxyReq.abort(); cleanup(); } socket.on('close', onclose); function onfinish () { debug.response('"finish" event'); cleanup(); } function cleanup () { debug.response('cleanup'); socket.removeListener('close', onclose); res.removeListener('finish', onfinish); return cb(); } if (Array.isArray(options.transformRequest)) { options.transformRequest.forEach(function(transform) { req = req.pipe(transform()); }); } else if (options.transformRequest) req = req.pipe(options.transformRequest()); req.pipe(proxyReq); }); } /** * HTTP CONNECT proxy requests. */ function onconnect (req, socket, head, options, cb) { debug.request('%s %s HTTP/%s ', req.method, req.url, req.httpVersion); assert(!head || 0 == head.length, '"head" should be empty for proxy requests'); var res; var ssocket = false; var target; var gotResponse = false; // define request socket event listeners function onclientclose (err) { debug.request('HTTP request %s socket "close" event', req.url); } socket.on('close', onclientclose); function onclientend () { debug.request('HTTP request %s socket "end" event', req.url); cleanup(); } function onclienterror (err) { debug.request('HTTP request %s socket "error" event:\n%s', req.url, err.stack || err); } socket.on('error', onclienterror); // define target socket event listeners function ontargetclose () { debug.proxyResponse('proxy target %s "close" event', req.url); cleanup(); socket.destroy(); } function ontargetend () { debug.proxyResponse('proxy target %s "end" event', req.url); cleanup(); } function ontargeterror (err) { debug.proxyResponse('proxy target %s "error" event:\n%s', req.url, err.stack || err); cleanup(); if (gotResponse) { debug.response('already sent a response, just destroying the socket...'); socket.destroy(); } else if ('ENOTFOUND' == err.code) { debug.response('HTTP/1.1 404 Not Found'); res.writeHead(404); res.end(); } else { debug.response('HTTP/1.1 500 Internal Server Error'); res.writeHead(500); res.end(); } } function ontargetconnect () { debug.proxyResponse('proxy target %s "connect" event', req.url); debug.response('HTTP/1.1 200 Connection established'); gotResponse = true; res.removeListener('finish', onfinish); res.writeHead(200, 'Connection established'); // HACK: force a flush of the HTTP header res._send(''); // relinquish control of the `socket` from the ServerResponse instance res.detachSocket(socket); // nullify the ServerResponse object, so that it can be cleaned // up before this socket proxying is completed res = null; ssocket = socket; if (Array.isArray(options.transformRequest)) { options.transformRequest.forEach(function(transform) { ssocket = ssocket.pipe(transform()); }); } else if (options.transformRequest) ssocket = ssocket.pipe(options.transformRequest()); ssocket.pipe(target); if (Array.isArray(options.transformResponse)) { options.transformResponse.forEach(function(transform) { target = target.pipe(transform()); }); } else if (options.transformResponse) target = target.pipe(options.transformResponse()); target.pipe(socket); } // cleans up event listeners for the `socket` and `target` sockets function cleanup () { debug.response('cleanup'); socket.removeListener('close', onclientclose); socket.removeListener('error', onclienterror); socket.removeListener('end', onclientend); if (target) { target.removeListener('connect', ontargetconnect); target.removeListener('close', ontargetclose); target.removeListener('error', ontargeterror); target.removeListener('end', ontargetend); } return cb(); } // create the `res` instance for this request since Node.js // doesn't provide us with one :( // XXX: this is undocumented API, so it will break some day (ノಠ益ಠ)ノ彡┻━┻ res = new http.ServerResponse(req); res.shouldKeepAlive = false; res.chunkedEncoding = false; res.useChunkedEncodingByDefault = false; res.assignSocket(socket); // called for the ServerResponse's "finish" event // XXX: normally, node's "http" module has a "finish" event listener that would // take care of closing the socket once the HTTP response has completed, but // since we're making this ServerResponse instance manually, that event handler // never gets hooked up, so we must manually close the socket... function onfinish () { debug.response('response "finish" event'); res.detachSocket(socket); socket.end(); } res.once('finish', onfinish); // pause the socket during authentication so no data is lost socket.pause(); authenticate(this, req, function (err, auth) { socket.resume(); if (err) { // an error occured during login! res.writeHead(500); res.end((err.stack || err.message || err) + '\n'); return; } if (!auth) return requestAuthorization(req, res); var parts = req.url.split(':'); var host = parts[0]; var port = +parts[1]; var opts = { host: host, port: port }; debug.proxyRequest('connecting to proxy target %j', opts); target = net.connect(opts); target.on('connect', ontargetconnect); target.on('close', ontargetclose); target.on('error', ontargeterror); target.on('end', ontargetend); }); } /** * Checks `Proxy-Authorization` request headers. Same logic applied to CONNECT * requests as well as regular HTTP requests. * * @param {http.Server} server * @param {http.ServerRequest} req * @param {Function} fn callback function * @api private */ function authenticate (server, req, fn) { var hasAuthenticate = 'function' == typeof server.authenticate; if (hasAuthenticate) { debug.request('authenticating request "%s %s"', req.method, req.url); server.authenticate(req, fn); } else { // no `server.authenticate()` function, so just allow the request fn(null, true); } } /** * Sends a "407 Proxy Authentication Required" HTTP response to the `socket`. * * @api private */ function requestAuthorization (req, res) { // request Basic proxy authorization debug.response('requesting proxy authorization for "%s %s"', req.method, req.url); // TODO: make "realm" and "type" (Basic) be configurable... var realm = 'proxy'; var headers = { 'Proxy-Authenticate': 'Basic realm="' + realm + '"' }; res.writeHead(407, headers); res.end(); }