Files
jupyterhub/lib/configproxy.js
2014-08-25 15:14:23 -07:00

284 lines
8.3 KiB
JavaScript

// A Configurable node-http-proxy
//
// POST, DELETE to /api/routes[:/path/to/proxy] to update the routing table
// GET /api/routes to see the current routing table
//
var http = require('http'),
httpProxy = require('http-proxy'),
log = require('loglevel');
var bound = function (that, method) {
// bind a method, to ensure `this=that` when it is called
// because prototype languages are bad
return function () {
method.apply(that, arguments);
};
};
var arguments_array = function (args) {
// cast arguments object to array, because Javascript.
return Array.prototype.slice.call(args, 0);
};
var json_handler = function (handler) {
// wrap json handler, so the handler is called with parsed data,
// rather than implementing streaming parsing in the handler itself
return function (req, res) {
var args = arguments_array(arguments);
var buf = '';
req.on('data', function (chunk) {
buf += chunk;
});
req.on('end', function () {
try {
data = JSON.parse(buf) || {};
} catch (e) {
that.fail(req, res, 400, "Body not valid JSON: " + e);
return;
}
args.push(data);
handler.apply(handler, args);
});
};
};
var authorized = function (method) {
/// decorator for token-authorized handlers
return function (req, res) {
if (!this.auth_token) {
return method.apply(this, arguments);
}
var match = (req.headers.authorization || '').match(/token\s+(\S+)/);
var token;
if (match !== null) {
token = match[1];
}
if (token == this.auth_token) {
return method.apply(this, arguments);
} else {
res.writeHead(403);
res.end();
}
};
};
var ConfigurableProxy = function (options) {
var that = this;
this.options = options || {};
this.auth_token = this.options.auth_token;
this.upstream_ip = this.options.upstream_ip || 'localhost';
this.upstream_port = this.options.upstream_port || 8081;
this.default_target = "http://" + this.upstream_ip + ":" + this.upstream_port;
this.routes = {};
var proxy = this.proxy = httpProxy.createProxyServer({
ws : true
});
// tornado-style regex routing,
// because cross-language cargo-culting is always a good idea
this.api_handlers = [
[ /^\/api\/routes$/, {
get : bound(this, authorized(this.get_routes))
} ],
[ /^\/api\/routes(\/.*)$/, {
post : json_handler(bound(this, authorized(this.post_routes))),
'delete' : bound(this, authorized(this.delete_routes))
} ]
];
var log_errors = function(handler) {
return function (req, res) {
try {
return handler.apply(that, arguments);
} catch (e) {
log.error("Error in handler for " +
req.method + ' ' + req.url + ': ', e
);
}
};
};
// handle API requests
this.api_server = http.createServer(
log_errors(that.handle_api_request)
);
// proxy web requests separately
this.proxy_server = http.createServer(
log_errors(that.handle_proxy_web)
);
// proxy websockets
this.proxy_server.on('upgrade', bound(this, this.handle_proxy_ws));
};
ConfigurableProxy.prototype.fail = function (req, res, code, msg) {
// log a failure, and finish the HTTP request with an error code
msg = msg || '';
log.error('[' + code + ']', req.method, req.url + ':', msg);
res.writeHead(code);
res.write(msg);
res.end();
};
ConfigurableProxy.prototype.add_route = function (path, data) {
// add a route to the routing table
this.routes[path] = data;
this.update_last_activity(path);
};
ConfigurableProxy.prototype.remove_route = function (path) {
// remove a route from teh routing table
if (this.routes[path] !== undefined) {
delete this.routes[path];
}
};
ConfigurableProxy.prototype.get_routes = function (req, res) {
// GET returns routing table as JSON dict
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write(JSON.stringify(this.routes));
res.end();
};
ConfigurableProxy.prototype.post_routes = function (req, res, path, data) {
// POST adds a new route
log.debug('POST', path, data);
// ensure path starts with /
if (path[0] != '/') {
path = '/' + path;
}
// ensure path *doesn't* end with /
if (path[path.length - 1] == '/') {
path = path.substr(0, path.length - 1);
}
this.add_route(path, data);
res.writeHead(201);
res.end();
};
ConfigurableProxy.prototype.delete_routes = function (req, res, path) {
// DELETE removes an existing route
log.debug('DELETE', path);
if (this.routes[path] === undefined) {
res.writeHead(404);
} else {
this.remove_route(path, data);
res.writeHead(202);
}
res.end();
};
var url_startswith = function (url, prefix) {
// does the url path start with prefix?
// use array splitting to match prefix and avoid trailing-slash and partial-word issues
prefix_parts = prefix.split('/');
parts = url.split('/');
if (parts.length < prefix_parts.length) {
return false;
}
for (var i = 0; i < prefix_parts.length; i++) {
if (prefix_parts[i] != parts[i]) {
return false;
}
}
return true;
};
ConfigurableProxy.prototype.target_for_url = function (url) {
// return proxy target for a given url path
for (var prefix in this.routes) {
if (url_startswith(url, prefix)) {
return [prefix, this.routes[prefix].target];
}
}
// no custom target, fall back to default
return ['/', this.default_target];
};
ConfigurableProxy.prototype.update_last_activity = function (prefix) {
// note last activity in routing table
if (prefix === '/') {
return;
}
this.routes[prefix].last_activity = new Date();
};
ConfigurableProxy.prototype.handle_proxy = function (kind, req, res) {
// proxy any request
var that = this;
// get the proxy target
var both = this.target_for_url(req.url);
var prefix = both[0];
var target = both[1];
log.debug("PROXY", kind.toUpperCase(), req.url, "to", target);
// pop method off the front
var args = arguments_array(arguments);
args.shift();
// add config argument
args.push({
target: target
});
// add error handling
args.push(function (e) {
log.error("Proxy error: ", e);
res.writeHead(502);
res.write("Proxy target missing");
res.end();
});
// update last activity timestamp in routing table
this.update_last_activity(prefix);
// update timestamp on any reply data as well (this includes websocket data)
req.on('data', function () {
that.update_last_activity(prefix);
});
res.on('data', function () {
that.update_last_activity(prefix);
});
// dispatch the actual method
this.proxy[kind].apply(this.proxy, args);
};
ConfigurableProxy.prototype.handle_proxy_ws = function (req, res, head) {
// Proxy a websocket request
return this.handle_proxy('ws', req, res, head);
};
ConfigurableProxy.prototype.handle_proxy_web = function (req, res) {
// Proxy a web request
return this.handle_proxy('web', req, res);
};
ConfigurableProxy.prototype.handle_api_request = function (req, res) {
// Handle a request to the REST API
for (var i = 0; i < this.api_handlers.length; i++) {
var pat = this.api_handlers[i][0];
var match = pat.exec(req.url);
if (match) {
var handlers = this.api_handlers[i][1];
var handler = handlers[req.method.toLowerCase()];
if (!handler) {
// 405 on found resource, but not found method
this.fail(req, res, 405, "Method not supported.");
return;
}
var args = [req, res];
match.slice(1).forEach(function (arg) { args.push(arg); });
handler.apply(handler, args);
return;
}
}
this.fail(req, res, 404);
};
exports.ConfigurableProxy = ConfigurableProxy;