NodeJs and Bouncy as Web Front End

I use node.js and bouncy as the web front end to my sites. While node.js is known for its performance, I value the extreme flexibility I get by using javascript. I want to explain how it works and show why you might enjoy using it too.

In addition to the usual hostname based forwarding, I wanted two features and found out I could not get them from the existing popular solutions: nginx, varnish, or haproxy.

  • websocket forwarding
  • SNI support

These things work in nodejs/bouncy, and then it got more amazing as it was trivial to add etag support and in-memory file caching. When the rails parameters exploit came out, it was easy to add specific protection for that, which would have been difficult with other setups.

The following block is bouncy's hello world. Listen on port 80 and forward all requests to a web app on localhost port 2500.

/* node.js/bouncy hello world */ var bouncy = require('bouncy')

bouncy(function (req, bounce) { bounce(2500) }).listen(80) /typo:code

Feature - hostname based forwarding

First things first, forward a variety of hostnames to different internal ports - a typical vhost setup. Im taking a shortcut with these code examples - assume they're inside the bouncy(function{}) block.

var host = req.headers.host; if (host.match(/site-a.com$/)) { bounce(2500) } if (host.match(/site-b.com$/)) { bounce(2600) } /typo:code

Feature - websocket forwarding

Surprise - there's no code for this feature because a websocket forward works just like an HTTP forward. Bouncy will connect the incoming connection directly to the websocket port and not get in the way of the stream at all. Perfect for websocket connections to websocket servers like socket.io or npm's websock library.

I was using nginx for a long time but it was lack of websocket support that got me to look for other options.

Feature - SNI support

This was a big deal to get support for and neither haproxy or varnish supports it (Jan 2013). The current development version of haproxy has support for SNI but I could not get it to work after many attempts of different arrangements of the haproxy config file.

The approach I use here, and there are certainly others, is to have the same .js file that sets up the bouncy on port 80, to setup a second bouncy on port 443. The initial ssl config object gets bouncy into SSL mode. The initial key and cert is bogus because the key and cert are specified by the incoming hostname. This bouncy on 443 will unwrap the SSL and forward the request to bouncy on port 80 which has all the vhost setup, etc.

var ssl = { key : fs.readFileSync('/etc/ssl/private/ssl-cert-snakeoil.key'), cert : fs.readFileSync('/etc/ssl/private/ssl-cert-snakeoil.crt'), SNICallback: sni_select };

bouncy(ssl, function (req, bounce) { bounce(80) }).listen(443).on('error', errlog);

function sni_select(hostname) { // optionally massage hostname to strip www here var ssl_path = '/etc/ssl/private/'+hostname var creds = { key : fs.readFileSync(ssl_path+'/private.key'), cert: fs.readFileSync(ssl_path+'/server.crt') } if(fs.existsSync(ssl_path+'/issuer.pem')) { creds.ca = fs.readFileSync('/etc/ssl/private/'+hostname+'/issuer.pem') } return crypto.createCredentials(creds).context } /typo:code

Feature - serve static files

Here's where things get interesting. There is no explicit support in bouncy to serve static files, but because its javascript running in node, its trivial to load files off disk and serve them to the client.

var fs = require('fs')

if (host.match(/staticfiles.com$/)) { var path = "/www/staticfiles.com/public/"+req.url if(fs.existsSync(path) && fs.statSync(path).isFile()) { var response = bounce.respond() fs.readFile(path, function(err,body) { response.end(body); }); } } /typo:code

Feature - etag support with ram cache

Wait a second! I dont want to hit the disk on every request for a static file. Okay, lets whip up an in-ram cache of static files, and add etag support while we're at it.

var fs = require('fs') var crypto = require('crypto') var mime = require('mime');

var etag_cache = {}

if (host.match(/staticfiles.com$/)) { var path = "/www/staticfiles.com/public/"+req.url if(fs.existsSync(path) && fs.statSync(path).isFile()) { var response = bounce.respond() if(typeof(req.headers["if-none-match"]) == "undefined" || req.headers["if-none-match"] != etag_cache[req.url]) { /* etag miss! / fs.readFile(path, function(err,body) { var shasum = crypto.createHash('sha1'); shasum.update(body) var etag = shasum.digest('base64') response.writeHead(200, { 'Content-Length': body.length, 'Etag': etag, 'Content-Type': mime.lookup(path) }); response.end(body); etag_cache[req.url] = etag }); } else { / etag hit! */ response.writeHead(304, { 'Etag': etag_cache[req.url] }); response.end(); } } }
/typo:code

Using a javascript hash as a cache works great and fast. It should be easy to push the urls and etags to another store like memcache or redis if you prefer.

Feature - SSL only sites

Wait, I only want to serve https to visitors of ssl-only.com! Isnt it a problem to forward to the port 80 bouncy? Its no problem! Have the bouncy on port 80 check the x-forwarded-proto! As a bonus, you give regular http users the 'old 301 response!

if (host.match(/ssl-only.com$/)) { if(req.headers['x-forwarded-proto'] == 'https') { bounce(2500) } else { var response = bounce.respond() var url = "https://ssl-only.com" response.writeHead(301, { 'Location': url }); response.end(); } } /typo:code

Feature - Rails parameter exploit protection

I love ruby on rails and have a number of rails sites on my server. When the mother of all rails exploits happened, I updated my rails apps, but I also added a level of protection at the web front end since any XML post request was suspicious. Just for fun, lets log what commands were attempted with this exploit.

if(content_type == "text/xml" || content_type == "application/xml") { // stop Rails remote code execution fs.writeFileSync('rails_rce.log', host+req.url+"\n"); req.on("data",function(data){ fs.appendFileSync('rails_rce.log',data.toString('utf8')) }) return } /typo:code

This was a huge win for easily modifying the behaviour of the web front end in ways not likely to be supported through the configuration files of nginx, haproxy, and varnish.

Wishlist

Ive come into only one real problem with this setup. Malformed http requests cause an exception to be thrown in the parsley http library that causes the bouncy script to crash. I need to get a better grip on error handling. Right now I have the script restart itself on crash but that's obviously non-optimal. Once this is fixed, I expect it to be as highly reliable as any other web front end. It might be simply catching the exception in the bouncy script, but I haven't looked into it yet.

30-Jan-2013 edit: two days after writing this post on bouncy 1.3, I learn of bouncy 2.0 which changes the API. After an initial attempt to upgrade the script to 2.0 causes mysterious failures, I went back to 1.3.

As far as features, what I would like is a way to change the configuration without dropping the existing cients. Currently changing the bouncy script means restarting node.js which kills the existing connections. Mongrel2, I have read, does a good job of this.

Another missing feature is to take action based on the response of the webapp. If at attempt to forward to port 2500 results in an http 500 response for example, the brower will receive the response when I would like to have the option to catch that error response in bouncy and redirect the browser to a different page/site.

tags: