diff --git a/.gitignore b/.gitignore index d7c4b02..615dd70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -node_modules/ rules/rules.txt hoxy-rules.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..62863d9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "node_modules/cheerio"] + path = node_modules/cheerio + url = git@github.com:sjorek/cheerio.git diff --git a/hoxy.js b/hoxy.js index 5cbc39a..37f3f48 100644 --- a/hoxy.js +++ b/hoxy.js @@ -33,253 +33,17 @@ var opts = require('tav').set({ }, }, "Hoxy, the web-hacking proxy.\nusage: node hoxy.js [--debug] [--rules=file] [--port=port]"); -var HTTP = require('http'); -var URL = require('url'); -var HTS = require('./lib/http-transaction-state.js'); -var Q = require('./lib/asynch-queue.js'); -var RULES = require('./lib/rules.js'); -var RDB = require('./lib/rules-db.js'); - -var proxyPort = opts.port || 8080; -var debug = opts.debug; - if (opts.args.length && parseInt(opts.args[0])) { - console.error('!!! old: please use --port=something to specify port. thank you. exiting.'); - process.exit(1); -} - -if (opts.stage && !(/^[a-z0-9-]+(\.[a-z0-9-]+)*(:\d+)?$/i).test(opts.stage)) { - console.error('error: stage must be of the form or : exiting.'); - process.exit(1); -} - -// done -// ############################################################################# -// startup version check - -(function(){ - /* - Requiring v0.4.x or higher because we depend on http client connection pooling. - Also because of jsdom. - */ - var requiredVer = [0,4]; - var actualVer = process.version.split('.').map(function(s){ - return parseInt(s.replace(/\D/g,'')); - }); - if (!(function(){ - for (var i=0;i requiredVer[i]) { - return true; - } - } - return true; - })() && !opts['no-version-check']){ - console.error('Error: '+projectName+' requires Node.js v'+requiredVer.join('.') - +' or higher but you\'re running '+process.version); - console.error('Use --no-version-check to attempt to run '+projectName+' without this check.'); - console.error('Quitting.'); - process.exit(1); - } -})(); - -// done -// ############################################################################# -// environment proxy config - -var useProxy, envProxy = process.env.HTTP_PROXY || process.env.http_proxy; -if(useProxy = !!envProxy) { - if(!/^http:\/\//.test(envProxy)) { envProxy = 'http://'+envProxy; } - var pEnvProxy = URL.parse(envProxy); - console.log('hoxy using proxy '+envProxy); + console.error('!!! old: please use --port=something to specify port. thank you. exiting.'); + process.exit(1); } -// done -// ############################################################################# -// error handling and subs - -// truncates a URL -function turl(url){ - if (url.length > 64) { - var pUrl = URL.parse(url); - var nurl = pUrl.protocol + '//' + pUrl.host; - nurl += '/...'+url.substring(url.length-10, url.length); - url = nurl; - } - return url; -} - -// debug-flag-aware error logger -function logError(err, errType, url) { - if (debug) { - console.error(errType+' error: '+turl(url)+': '+err.message); - } -} - -// end err handling -// ############################################################################# -// create proxy server - -var stripRqHdrs = [ - 'accept-encoding', - 'proxy-connection', // causes certain sites to hang - 'proxy-authorization', -]; - -HTTP.createServer(function handleRequest(request, response) { - - // Handle the case where people put http://hoxy.host:port/ directly into - // their browser's location field, rather than configuring hoxy.host:port in - // their browser's proxy settings. In such cases, the URL won't have a - // scheme or host. This is what staging mode is for, since it provides a - // scheme and host in the absence of one. - if (/^\//.test(request.url) && opts.stage){ - request.url = 'http://'+opts.stage+request.url; - request.headers.host = opts.stage; - } - - // strip out certain request headers - stripRqHdrs.forEach(function(name){ - delete request.headers[name]; - }); - - // grab fresh copy of rules for each request - var rules = RDB.getRules(); - - var hts = new HTS.HttpTransactionState(); - hts.setRequest(request, function(reqInfo){ - // entire request body is now loaded - // process request phase rules - var reqPhaseRulesQ = new Q.AsynchQueue(); - rules.filter(function(rule){ - return rule.phase==='request'; - }).forEach(function(rule){ - reqPhaseRulesQ.push(rule.getExecuter(hts)); - }); - - reqPhaseRulesQ.execute(function(){ - - // request phase rules are now done processing. try to send the - // response directly without hitting up the server for a response. - // obviously, this will only work if the response was somehow - // already populated, e.g. during request-phase rule processing - // otherwise it throws an error and we send for the response. - try { - hts.doResponse(sendResponse); - } catch (ex) { - - // make sure content-length jibes - if (!reqInfo.body.length) { - reqInfo.headers['content-length'] = 0; - } else if (reqInfo.headers['content-length']!==undefined) { - var len = 0; - reqInfo.body.forEach(function(chunk){ - len += chunk.length; - }); - reqInfo.headers['content-length'] = len; - } else { /* node will send a chunked request */ } - - // make sure host header jibes - if(reqInfo.headers.host){ - reqInfo.headers.host = reqInfo.hostname; - if (reqInfo.port !== 80) { - reqInfo.headers.host += ':'+reqInfo.port; - } - } - - // this method makes node re-use client objects if needed - var proxyReq = HTTP.request({ - method: reqInfo.method, - host: useProxy ? pEnvProxy.hostname : reqInfo.hostname, - port: useProxy ? pEnvProxy.port : reqInfo.port, - path: useProxy ? reqInfo.absUrl : reqInfo.url, - headers: reqInfo.headers, - },function(proxyResp){ - hts.setResponse(proxyResp, sendResponse); - }); - - // write out to dest server - var reqBodyQ = new Q.AsynchQueue(); - reqInfo.body.forEach(function(chunk){ - reqBodyQ.push(function(notifier){ - proxyReq.write(chunk); - setTimeout(function(){ - notifier.notify(); - }, reqInfo.throttle); - }); - }); - reqBodyQ.execute(function(){ - proxyReq.end(); - }); - } - - // same subroutine used in either case - function sendResponse(respInfo) { - - // entire response body is now available - // do response phase rule processing - var respPhaseRulesQ = new Q.AsynchQueue(); - rules.filter(function(rule){ - return rule.phase==='response'; - }).forEach(function(rule){ - respPhaseRulesQ.push(rule.getExecuter(hts)); - }); - - respPhaseRulesQ.execute(function(){ - - // response phase rules are now done processing - // send response, but first drop this little hint - // to let client know something fishy's going on - respInfo.headers['x-manipulated-by'] = projectName; - - // shore up the content-length situation - if (!respInfo.body.length) { - respInfo.headers['content-length'] = 0; - } else if (respInfo.headers['content-length']!==undefined) { - var len = 0; - respInfo.body.forEach(function(chunk){ - len += chunk.length; - }); - respInfo.headers['content-length'] = len; - } else { /* node will send a chunked response */ } - - // write headers, queue up body writes, send, end and done - response.writeHead(respInfo.statusCode, respInfo.headers); - var respBodyQ = new Q.AsynchQueue(); - respInfo.body.forEach(function(chunk){ - respBodyQ.push(function(notifier){ - response.write(chunk); - setTimeout(function(){ - notifier.notify(); - }, respInfo.throttle); - }); - }); - respBodyQ.execute(function(){ - response.end(); - }); - }); - } - }); - }); -}).listen(proxyPort); - -// done creating proxy -// ############################################################################# -// print a nice info message - -console.log(projectName+' running at localhost:'+proxyPort); -if (opts.stage) console.log('staging mode is on. http://localhost:'+proxyPort+'/ will stage for http://'+opts.stage+'/'); -if (debug) console.log('debug mode is on.'); - -// done with message -// ############################################################################# -// start catching errors +require('./runner.js')(projectName, opts); // helps to ensure the proxy stays up and running process.on('uncaughtException',function(err){ - if (debug) { - console.error('uncaught exception: '+err.message); - console.error(err.stack); - } -}); + if (debug) { + console.error('uncaught exception: '+err.message); + console.error(err.stack); + } +}); \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..437c19e --- /dev/null +++ b/index.js @@ -0,0 +1,26 @@ +module.exports = function(opts){ + + opts = opts || {}; + var defaults = { + projectName: 'Hoxy', + debug: false, //Turn on debug mode, print errors to console. + rules: './hoxy-rules.txt', //Specify rules file location + port: 8080, //Specify port to listen on. + stage: false, //Host that proxy will act as a staging server for. + 'no-version-check': false, //Attempt to run proxy without the startup version check. + pluginPath: null //function that takes a name and returns path to plugin that can be required + }; + + for(var key in defaults){ + if(defaults.hasOwnProperty(key)){ + if(typeof opts[key] == 'undefined'){ + opts[key] = defaults[key]; + } + } + } + var projectName = opts.projectName; + delete opts.projectName; + require('./runner.js')(projectName, opts); + + +} \ No newline at end of file diff --git a/lib/http-transaction-state.js b/lib/http-transaction-state.js index 582ff12..a541bd3 100644 --- a/lib/http-transaction-state.js +++ b/lib/http-transaction-state.js @@ -8,6 +8,7 @@ http://github.com/greim // imports var URL = require('url'); +var opts = require('tav'); // ############################################################################# // subroutines @@ -112,6 +113,10 @@ var actionSchema = { }, }; +exports.pluginPath = function(name) { + return '../plugins/' + name + '.js' +} + exports.getActionValidator = function(action) { if (!actionSchema[action]) { return false; } return { @@ -242,11 +247,17 @@ var thingSchema = { }, 'request-headers':{ keys:1, - get:function(reqi, respi, name){ return reqi.headers[name]; }, + get:function(reqi, respi, name){ + return name == '*' ? reqi.headers : reqi.headers[name]; + }, map:function(reqi, respi, mapper, name){ - var val = mapper(reqi.headers[name]); - if (!val) { delete reqi.headers[name]; } - else { reqi.headers[name] = val; } + if (name == '*') { + mapper(reqi.headers); + } else { + var val = mapper(reqi.headers[name]); + if (!val) { delete reqi.headers[name]; } + else { reqi.headers[name] = val; } + } }, availability:['request','response'], aliases:['qh'], @@ -392,11 +403,18 @@ var thingSchema = { }, 'response-headers':{ keys:1, - get:function(reqi, respi, name){ return respi.headers[name]; }, + get:function(reqi, respi, name){ + return name == '*' ? respi.headers : respi.headers[name]; + }, map:function(reqi, respi, mapper, name){ - var val = mapper(respi.headers[name]); - if (!val) { delete respi.headers[name]; } - else { respi.headers[name] = val; } + if (name == '*') { + mapper(respi.headers); + } else { + var val = mapper(respi.headers[name]); + if (!val) { delete respi.headers[name]; } + else { respi.headers[name] = val; } + } + }, availability:['response'], aliases:['sh'], @@ -482,6 +500,24 @@ var thingSchema = { aliases:['body'], description:'Response body in its entirety, represented as a string. Beware binary data.', }, + 'hoxy-configuration':{ + keys:1, + get:function(reqi, respi, key){ + var keys = key.split('.'), + opt = opts; + while ((key = keys.shift()) && !(typeof opts[key] == 'undefined')) { + opt = opts[key] + } + return opt; + }, + map:function(reqi, respi, mapper, key) { + var val = mapper(this.get(reqi, respi, key)); + if (!val) { /* // intentionally left blank // delete respi.headers[name]; */ } + else { /* // intentionally left blank // respi.headers[name] = val; */ } + }, + availability:['request','response'], + aliases:['hoxy'], + } }; // gen docs from schema @@ -554,6 +590,8 @@ exports.printDocs = function(){ var INFO = require('./http-info.js'); +var pluginRegister = {}; + // represents the ongoing state of an HTTP transaction exports.HttpTransactionState = function(){ var htState = this, @@ -656,6 +694,9 @@ exports.HttpTransactionState = function(){ getResponseInfo:function(){ return respInf; }, + getHoxyConfiguration:function(){ + return opts; + }, state: htState, notify: function(err){ if (err && err.message) { @@ -665,15 +706,32 @@ exports.HttpTransactionState = function(){ notifier.notify(); }, }; - var filename = '../plugins/' + name + '.js'; - try{ - var plugin = require(filename); - }catch(err){ - notifier.notify(); - throw new Error('failed to load plugin "'+name+'": '+err.message); + var plugin = pluginRegister[name] || null; + if (plugin === null) { + try{ + plugin = require('hoxy-' + name); + }catch(err){ + plugin = null; + } + pluginRegister[name] = plugin; + } + if (plugin === null) { + var filename = '../plugins/' + name + '.js'; + try{ + plugin = require(filename); + }catch(err){ + var filename = exports.pluginPath(name); + try{ + plugin = require(filename); + }catch(err){ + notifier.notify(); + throw new Error('failed to load plugin "'+name+'": '+err.message); + } + } + pluginRegister[name] = plugin; } try{ - plugin.run(api); + plugin.run(api, module.exports); }catch(err){ // WARNING: plugin is always responsible to notify, even if it throws errors throw new Error('error running plugin "'+name+'": '+err.message); diff --git a/lib/rules-db.js b/lib/rules-db.js index 63bcfd0..5289629 100644 --- a/lib/rules-db.js +++ b/lib/rules-db.js @@ -8,61 +8,68 @@ var FS = require('fs'); var PATH = require('path'); var RULES = require('./rules.js'); -var opts = require('tav'); + var xmpFile = PATH.normalize(__dirname + '/../rules/rules-example.txt'); -var file = opts.rules; + var rules = []; var overrideRules = false; -FS.exists(file, function(fileExists){ - FS.exists(xmpFile, function(xmpFileExists){ - if (!fileExists) { - if (!xmpFileExists) { - console.error('error: '+file+' doesn\'t exist, exiting'); - process.exit(1); - } else { - try { - var xmpCont = FS.readFileSync(xmpFile, 'utf8'); - FS.writeFileSync(file, xmpCont, 'utf8'); - console.log('copying '+xmpFile+' to '+file); - } catch(err){ - console.error(err.message); - process.exit(1); - } - } - } - var opts = {persistent: true, interval: 500}; - FS.watchFile(file, opts, loadRules); - loadRules(); - }); -}); -var emt = /\S/; -var comment = /^\s*#/; -function loadRules(cur, prev) { - if (cur && (cur.mtime.getTime() === prev.mtime.getTime())) { - return; - } - FS.readFile(file, 'utf8', function(err, data){ - if (err) { throw err; } - rules = data.split('\n') - .filter(function(ruleStr){ - return emt.test(ruleStr) && !comment.test(ruleStr); - }) - .map(function(ruleStr){ - try { return new RULES.Rule(ruleStr); } - catch (ex) { - console.error( - 'error parsing '+file+': '+ex.message - +'\nignoring entire rule, please fix' - ); - return false; - } - }) - .filter(function(rule){ - return !!rule; - }); - }); + +exports.init = function(opts){ + + var file = opts.rules; + + FS.exists(file, function(fileExists){ + FS.exists(xmpFile, function(xmpFileExists){ + if (!fileExists) { + if (!xmpFileExists) { + console.error('error: '+file+' doesn\'t exist, exiting'); + process.exit(1); + } else { + try { + var xmpCont = FS.readFileSync(xmpFile, 'utf8'); + FS.writeFileSync(file, xmpCont, 'utf8'); + console.log('copying '+xmpFile+' to '+file); + } catch(err){ + console.error(err.message); + process.exit(1); + } + } + } + var opts = {persistent: true, interval: 500}; + FS.watchFile(file, opts, loadRules); + loadRules(); + }); + }); + + var emt = /\S/; + var comment = /^\s*#/; + function loadRules(cur, prev) { + if (cur && (cur.mtime.getTime() === prev.mtime.getTime())) { + return; + } + FS.readFile(file, 'utf8', function(err, data){ + if (err) { throw err; } + rules = data.split('\n') + .filter(function(ruleStr){ + return emt.test(ruleStr) && !comment.test(ruleStr); + }) + .map(function(ruleStr){ + try { return new RULES.Rule(ruleStr); } + catch (ex) { + console.error( + 'error parsing '+file+': '+ex.message + +'\nignoring entire rule, please fix' + ); + return false; + } + }) + .filter(function(rule){ + return !!rule; + }); + }); + } } exports.getRules = function(){return overrideRules || rules;}; diff --git a/node_modules/.gitignore b/node_modules/.gitignore new file mode 100644 index 0000000..3d9a205 --- /dev/null +++ b/node_modules/.gitignore @@ -0,0 +1,7 @@ +/await +/html +/js-beautify +/mime +/PrettyCSS +/tav +/.bin diff --git a/node_modules/cheerio b/node_modules/cheerio new file mode 160000 index 0000000..2e71d37 --- /dev/null +++ b/node_modules/cheerio @@ -0,0 +1 @@ +Subproject commit 2e71d3725c37b86d406d65e049bd98ccc91d89ea diff --git a/package.json b/package.json index 381b935..a7002a0 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,6 @@ "bin": { "hoxy": "./bin/hoxy" }, - "repository": { - "type": "git", - "url": "git://github.com/greim/hoxy.git" - }, "keywords": [ "web", "traffic", @@ -26,7 +22,8 @@ "js-beautify": "0.2.x", "cheerio": "0.10.x", "PrettyCSS": "0.3.x", - "html": "0.0.x" + "html": "0.0.x", + "mime": "1.2.x" }, "license": "MIT", "engines": { diff --git a/plugins/banner.js b/plugins/banner.js index ea4aa72..4114d15 100644 --- a/plugins/banner.js +++ b/plugins/banner.js @@ -43,9 +43,14 @@ exports.run = function(api){ for (var q in defs){ styleString += q+':'+defs[q]+';' } + var append = api.arg(2) || false; try { var banner = '
'+contents+'
'; - html=html.replace(/]*)>/, ''+banner); + if (append) { + html=html.replace(/<\/body([^>]*)>/, banner+''); + } else { + html=html.replace(/]*)>/, ''+banner); + } api.setResponseBody(html); } catch (ex) { console.log("banner error: "+ex.message); diff --git a/plugins/change-location.js b/plugins/change-location.js new file mode 100644 index 0000000..53eb261 --- /dev/null +++ b/plugins/change-location.js @@ -0,0 +1,36 @@ +/** + * using a regexp replacement, replace the location header value as well as do a global replacement in the response body + * + * useful for repointing static file refs to your proxy + * + * use: + * response: @change-location('programmingdrunk\.com', 'localhost:8080') + * @param api + */ + +exports.run = function(api){ + var res = api.getResponseInfo(); + + + var fromRegexp = new RegExp(api.arg(0), 'gi'); + var to = api.arg(1); + + if(res.headers['location']){ + res.headers['location'] = res.headers['location'].replace(fromRegexp, to); + } + + var ct = api.getResponseInfo().headers['content-type']; + if (ct && ct.indexOf('html')>-1) { + var html = api.getResponseBody(); + try { + api.setResponseBody(html.replace(fromRegexp, to)); + api.notify(); + } catch (ex) { + api.notify(ex); + } + } else { + api.notify(); + } + + +}; diff --git a/plugins/cheerio-eval.js b/plugins/cheerio-eval.js index 0013637..fa3b71f 100644 --- a/plugins/cheerio-eval.js +++ b/plugins/cheerio-eval.js @@ -25,13 +25,14 @@ var VM = require('vm'); exports.run = function(api){ var respInf = api.getResponseInfo(); if (/html/.test(respInf.headers['content-type'])) { - var code = api.arg(0); - var html = api.getResponseBody(); + var code = api.arg(0), + html = api.getResponseBody(), + opts = {xmlMode:!!api.arg(1), ignoreWhitespace:!!api.arg(2)}; try{ var script = VM.createScript(code); - var window = {$:CHEERIO.load(html)}; + var window = {$:CHEERIO.load(html, opts)}; script.runInNewContext(window); - var newHTML = window.$.html(); + var newHTML = window.$.html(null, opts); api.setResponseBody(newHTML); api.notify(); }catch(err){ diff --git a/plugins/cheerio-script.js b/plugins/cheerio-script.js index 665351c..38e3d94 100644 --- a/plugins/cheerio-script.js +++ b/plugins/cheerio-script.js @@ -34,15 +34,17 @@ function getScript(path){ exports.run = function(api){ var respInf = api.getResponseInfo(); if (/html/.test(respInf.headers['content-type'])) { - var path = api.arg(0); + var path = api.arg(0), + opts = {xmlMode:!!api.arg(1), ignoreWhitespace:!!api.arg(2)}; + getScript(path) .onkeep(function(got){ var html = api.getResponseBody(); try{ var script = VM.createScript(got.code); - var window = {$:CHEERIO.load(html)}; + var window = {$:CHEERIO.load(html, opts)}; script.runInNewContext(window); - var newHTML = window.$.html(); + var newHTML = window.$.html(null, opts); api.setResponseBody(newHTML); api.notify(); }catch(err){ diff --git a/plugins/ghost-server.js b/plugins/ghost-server.js index 73de295..78fc7a9 100644 --- a/plugins/ghost-server.js +++ b/plugins/ghost-server.js @@ -29,6 +29,7 @@ use that instead of 'index.html', for example: @ghost-server('/some/path','home. var PATH = require('path'); var URL = require('url'); var FS = require('fs'); +var MIME = require('mime'); exports.run = function(api){ var htdocs = api.arg(0); @@ -118,20 +119,6 @@ exports.run = function(api){ }); }; -// todo: use an actual mime types lib -var ctypes = { - '.html':'text/html', - '.shtml':'text/html', - '.htm':'text/html', - '.css':'text/css', - '.js':'text/javascript', - '.gif':'image/gif', - '.png':'image/png', - '.jpg':'image/jpeg', - '.jpeg':'image/jpeg', - '.xml':'application/xml', - '.xsl':'application/xml', -}; function isText(ctype) { return ctype.indexOf('text') > -1 || ctype.indexOf('xml') > -1; @@ -139,8 +126,10 @@ function isText(ctype) { function getContentType(path, accept){ accept = accept || 'text/plain'; accept = accept.split(',')[0]; - var ext = PATH.extname(path).toLowerCase() || accept; - var ctype = ctypes[ext]; - if(ctype && isText(ctype)){ ctype += '; charset=utf-8'; } + var ctype = MIME.lookup(path) || accept, + cset = MIME.charsets.lookup(ctype); + if(ctype && isText(ctype) && cset){ + ctype += '; charset=' + cset; + } return ctype; } diff --git a/plugins/rewrite-static.js b/plugins/rewrite-static.js new file mode 100644 index 0000000..9c9bc17 --- /dev/null +++ b/plugins/rewrite-static.js @@ -0,0 +1,40 @@ +var HTTP = require('http'); +var URL = require('url'); +var HTS = require('../lib/http-transaction-state.js'); + +/** + * + * specialized version of internal-redirect + * + * say the static url for a resource is: //localhost:8080/static/2931fd4/css/stuff.css + * you have a local server ready to server up that4 resource at //localhost:7777/css/stuff.css + * + * then do this: + * + * request: if $ext eq "css", @rewrite-static('http://localhost:7777', '/staticVer/12342354/css', '/css') + * + * @param api + */ +exports.run = function(api) { + var url = api.arg(0); + var replaceRegexp = new RegExp(api.arg(1)); + var replaceWith = api.arg(2); + + var reqUrl = api.getRequestInfo().url.replace(replaceRegexp, replaceWith); + + var pUrl = URL.parse(url); + var port = parseInt(pUrl.port) || 80; + var hostname = pUrl.hostname; + + var client = HTTP.createClient(port, hostname); + + var clientReq = client.request('GET', reqUrl, { host: hostname }); + clientReq.end(); + clientReq.on('response', function(resp) { + var hts = new HTS.HttpTransactionState(); + hts.setResponse(resp, function(respInf){ + api.setResponseInfo(respInf); + api.notify(); + }); + }); +}; \ No newline at end of file diff --git a/runner.js b/runner.js new file mode 100644 index 0000000..73b6335 --- /dev/null +++ b/runner.js @@ -0,0 +1,247 @@ +var HTTP = require('http'); +var URL = require('url'); +var HTS = require('./lib/http-transaction-state.js'); +var Q = require('./lib/asynch-queue.js'); +var RULES = require('./lib/rules.js'); +var RDB = require('./lib/rules-db.js'); + + +module.exports = function(projectName, opts){ + var proxyPort = opts.port || 8080; + var debug = opts.debug; + + + if (opts.stage && !(/^[a-z0-9-]+(\.[a-z0-9-]+)*(:\d+)?$/i).test(opts.stage)) { + console.error('error: stage must be of the form or : exiting.'); + process.exit(1); + } + + HTS.pluginPath = opts.pluginPath || function(name){ return name}; + + RDB.init(opts); + + // done + // ############################################################################# + // startup version check + + (function(){ + /* + Requiring v0.4.x or higher because we depend on http client connection pooling. + Also because of jsdom. + */ + var requiredVer = [0,4]; + var actualVer = process.version.split('.').map(function(s){ + return parseInt(s.replace(/\D/g,'')); + }); + if (!(function(){ + for (var i=0;i requiredVer[i]) { + return true; + } + } + return true; + })() && !opts['no-version-check']){ + console.error('Error: '+projectName+' requires Node.js v'+requiredVer.join('.') + +' or higher but you\'re running '+process.version); + console.error('Use --no-version-check to attempt to run '+projectName+' without this check.'); + console.error('Quitting.'); + process.exit(1); + } + })(); + + // done + // ############################################################################# + // environment proxy config + + var useProxy, envProxy = process.env.HTTP_PROXY || process.env.http_proxy; + if(useProxy = !!envProxy) { + if(!/^http:\/\//.test(envProxy)) { envProxy = 'http://'+envProxy; } + var pEnvProxy = URL.parse(envProxy); + console.log('hoxy using proxy '+envProxy); + } + + // done + // ############################################################################# + // error handling and subs + + // truncates a URL + function turl(url){ + if (url.length > 64) { + var pUrl = URL.parse(url); + var nurl = pUrl.protocol + '//' + pUrl.host; + nurl += '/...'+url.substring(url.length-10, url.length); + url = nurl; + } + return url; + } + + // debug-flag-aware error logger + function logError(err, errType, url) { + if (debug) { + console.error(errType+' error: '+turl(url)+': '+err.message); + } + } + + // end err handling + // ############################################################################# + // create proxy server + + var stripRqHdrs = [ + 'accept-encoding', + 'proxy-connection', // causes certain sites to hang + 'proxy-authorization', + ]; + + HTTP.createServer(function handleRequest(request, response) { + + // Handle the case where people put http://hoxy.host:port/ directly into + // their browser's location field, rather than configuring hoxy.host:port in + // their browser's proxy settings. In such cases, the URL won't have a + // scheme or host. This is what staging mode is for, since it provides a + // scheme and host in the absence of one. + if (/^\//.test(request.url) && opts.stage){ + request.url = 'http://'+opts.stage+request.url; + request.headers.host = opts.stage; + } + + // strip out certain request headers + stripRqHdrs.forEach(function(name){ + delete request.headers[name]; + }); + + // grab fresh copy of rules for each request + var rules = RDB.getRules(); + + var hts = new HTS.HttpTransactionState(); + hts.setRequest(request, function(reqInfo){ + // entire request body is now loaded + // process request phase rules + var reqPhaseRulesQ = new Q.AsynchQueue(); + rules.filter(function(rule){ + return rule.phase==='request'; + }).forEach(function(rule){ + reqPhaseRulesQ.push(rule.getExecuter(hts)); + }); + + reqPhaseRulesQ.execute(function(){ + + // request phase rules are now done processing. try to send the + // response directly without hitting up the server for a response. + // obviously, this will only work if the response was somehow + // already populated, e.g. during request-phase rule processing + // otherwise it throws an error and we send for the response. + try { + hts.doResponse(sendResponse); + } catch (ex) { + + // make sure content-length jibes + if (!reqInfo.body.length) { + reqInfo.headers['content-length'] = 0; + } else if (reqInfo.headers['content-length']!==undefined) { + var len = 0; + reqInfo.body.forEach(function(chunk){ + len += chunk.length; + }); + reqInfo.headers['content-length'] = len; + } else { /* node will send a chunked request */ } + + // make sure host header jibes + if(reqInfo.headers.host){ + reqInfo.headers.host = reqInfo.hostname; + if (reqInfo.port !== 80) { + reqInfo.headers.host += ':'+reqInfo.port; + } + } + + // this method makes node re-use client objects if needed + var proxyReq = HTTP.request({ + method: reqInfo.method, + host: useProxy ? pEnvProxy.hostname : reqInfo.hostname, + port: useProxy ? pEnvProxy.port : reqInfo.port, + path: useProxy ? reqInfo.absUrl : reqInfo.url, + headers: reqInfo.headers, + },function(proxyResp){ + hts.setResponse(proxyResp, sendResponse); + }); + + // write out to dest server + var reqBodyQ = new Q.AsynchQueue(); + reqInfo.body.forEach(function(chunk){ + reqBodyQ.push(function(notifier){ + proxyReq.write(chunk); + setTimeout(function(){ + notifier.notify(); + }, reqInfo.throttle); + }); + }); + reqBodyQ.execute(function(){ + proxyReq.end(); + }); + } + + // same subroutine used in either case + function sendResponse(respInfo) { + + // entire response body is now available + // do response phase rule processing + var respPhaseRulesQ = new Q.AsynchQueue(); + rules.filter(function(rule){ + return rule.phase==='response'; + }).forEach(function(rule){ + respPhaseRulesQ.push(rule.getExecuter(hts)); + }); + + respPhaseRulesQ.execute(function(){ + + // response phase rules are now done processing + // send response, but first drop this little hint + // to let client know something fishy's going on + respInfo.headers['x-manipulated-by'] = projectName; + + // shore up the content-length situation + if (!respInfo.body.length) { + respInfo.headers['content-length'] = 0; + } else if (respInfo.headers['content-length']!==undefined) { + var len = 0; + respInfo.body.forEach(function(chunk){ + len += chunk.length; + }); + respInfo.headers['content-length'] = len; + } else { /* node will send a chunked response */ } + + // write headers, queue up body writes, send, end and done + response.writeHead(respInfo.statusCode, respInfo.headers); + var respBodyQ = new Q.AsynchQueue(); + respInfo.body.forEach(function(chunk){ + respBodyQ.push(function(notifier){ + response.write(chunk); + setTimeout(function(){ + notifier.notify(); + }, respInfo.throttle); + }); + }); + respBodyQ.execute(function(){ + response.end(); + }); + }); + } + }); + }); + }).listen(proxyPort); + + // done creating proxy + // ############################################################################# + // print a nice info message + + console.log(projectName+' running at localhost:'+proxyPort); + if (opts.stage) console.log('staging mode is on. http://localhost:'+proxyPort+'/ will stage for http://'+opts.stage+'/'); + if (debug) console.log('debug mode is on.'); + + // done with message + // ############################################################################# + // start catching errors + + +}; \ No newline at end of file diff --git a/test-plugin.js b/test-plugin.js new file mode 100644 index 0000000..0ffcb94 --- /dev/null +++ b/test-plugin.js @@ -0,0 +1,9 @@ +/* +this is just a plugin that tests the ability to add a path of where plugins are found. see test.js +*/ + +exports.run = function(api) { + api.notify() +}; + + diff --git a/test_require.js b/test_require.js new file mode 100644 index 0000000..95a7f87 --- /dev/null +++ b/test_require.js @@ -0,0 +1,12 @@ +var hoxy = require('./index.js') +var path = require('path') + +hoxy({ + projectName: 'Test require', + debug: true, + port: 8080, + stage: 'programmingdrunk.com:80', + pluginPath: function(name) { + return path.join(__dirname, name + '.js'); + } +});