mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 07:23:00 +00:00
init with basic working example
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
*.pyx
|
||||||
|
*~
|
||||||
|
.DS_Store
|
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Proof of concept for configurable proxy
|
||||||
|
|
||||||
|
This is a proof of concept implementation of a configurable proxy for managed multi-user webapps,
|
||||||
|
ultimately for use in IPython.
|
||||||
|
|
||||||
|
|
||||||
|
Three actors:
|
||||||
|
|
||||||
|
- multi user server (tornado process)
|
||||||
|
- configurable http proxy (node-http-proxy)
|
||||||
|
- multiple single user servers (tornado)
|
||||||
|
|
||||||
|
Basic principals:
|
||||||
|
|
||||||
|
- MUS spawns proxy
|
||||||
|
- proxy forwards ~all requests to MUS by default
|
||||||
|
- MUS handles login, and spawns single-user servers on demand
|
||||||
|
- MUS configures proxy to forward url prefixes to single-user servers
|
||||||
|
|
||||||
|
## dependencies
|
||||||
|
|
||||||
|
npm install node-http-proxy
|
||||||
|
pip install tornado
|
||||||
|
|
||||||
|
## to use
|
||||||
|
|
||||||
|
$> python multiuser.py
|
||||||
|
|
||||||
|
visit `http://localhost:8000`, and login (any username, password=`password`).
|
||||||
|
|
159
lib/configproxy.js
Normal file
159
lib/configproxy.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
var http = require('http'),
|
||||||
|
httpProxy = require('http-proxy');
|
||||||
|
// director = require('director');
|
||||||
|
|
||||||
|
var bound = function (that, method) {
|
||||||
|
return function () {
|
||||||
|
method.apply(that, arguments);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
var arguments_array = function (args) {
|
||||||
|
// cast arguments object to array, because it isn't one already (?!)
|
||||||
|
return Array.prototype.slice.call(args, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
var json_handler = function (handler) {
|
||||||
|
// wrap json handler
|
||||||
|
return function (req, res) {
|
||||||
|
var args = arguments_array(arguments);
|
||||||
|
var buf = '';
|
||||||
|
req.on('data', function (chunk) {
|
||||||
|
console.log('data', chunk);
|
||||||
|
buf += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', function () {
|
||||||
|
console.log('buf', buf);
|
||||||
|
try {
|
||||||
|
data = JSON.parse(buf) || {};
|
||||||
|
} catch (e) {
|
||||||
|
that.fail(res, 400, "Body not valid JSON: " + e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
args.push(data);
|
||||||
|
handler.apply(handler, args);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
var ConfigurableProxy = function (options) {
|
||||||
|
var that = this;
|
||||||
|
this.options = options || {};
|
||||||
|
this.upstream_port = this.options.upstream_port || 8001;
|
||||||
|
this.default_target = 'http://localhost:' + this.upstream_port;
|
||||||
|
this.routes = {};
|
||||||
|
|
||||||
|
this.proxy = httpProxy.createProxyServer({
|
||||||
|
ws : true
|
||||||
|
});
|
||||||
|
// tornado-style regex routing,
|
||||||
|
// because cross-language cargo-culting is always a good idea
|
||||||
|
|
||||||
|
this.handlers = [
|
||||||
|
[ /^\/api\/routes$/, {
|
||||||
|
get : bound(this, this.get_routes)
|
||||||
|
} ],
|
||||||
|
[ /^\/api\/routes(\/.*)$/, {
|
||||||
|
post : json_handler(bound(this, this.post_routes)),
|
||||||
|
'delete' : bound(this, this.delete_routes)
|
||||||
|
} ]
|
||||||
|
];
|
||||||
|
|
||||||
|
this.server = http.createServer(
|
||||||
|
function (req, res) {
|
||||||
|
try {
|
||||||
|
return that.handle_request(req, res);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Error in handler for " +
|
||||||
|
req.method + ' ' + req.url + ': ', e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.listen = function (port) {
|
||||||
|
this.server.listen(port);
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.fail = function (res, code, msg) {
|
||||||
|
res.writeHead(code);
|
||||||
|
res.write(msg);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.add_route = function (path, data) {
|
||||||
|
this.routes[path] = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.remove_route = function (path, data) {
|
||||||
|
if (this.routes[path] !== undefined) {
|
||||||
|
delete this.routes[path];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.get_routes = function (req, res) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.write(JSON.stringify(this.routes));
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.post_routes = function (req, res, path, data) {
|
||||||
|
console.log('post', path, data);
|
||||||
|
this.add_route(path, data);
|
||||||
|
res.writeHead(201);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.delete_routes = function (req, res, path) {
|
||||||
|
console.log('delete', path);
|
||||||
|
this.add_route(path, data);
|
||||||
|
res.writeHead(202);
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.target_for_url = function (url) {
|
||||||
|
// return proxy target for a given url
|
||||||
|
for (var path in this.routes) {
|
||||||
|
if (url.indexOf(path) === 0) {
|
||||||
|
return this.routes[path].target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no custom target, fall back to default
|
||||||
|
return this.default_target;
|
||||||
|
};
|
||||||
|
|
||||||
|
ConfigurableProxy.prototype.handle_request = function (req, res) {
|
||||||
|
console.log("handle", req.method, req.url);
|
||||||
|
for (var i = 0; i < this.handlers.length; i++) {
|
||||||
|
var pat = this.handlers[i][0];
|
||||||
|
var match = pat.exec(req.url);
|
||||||
|
if (match) {
|
||||||
|
var handlers = this.handlers[i][1];
|
||||||
|
var handler = handlers[req.method.toLowerCase()];
|
||||||
|
if (!handler) {
|
||||||
|
// 405 on found resource, but not found method
|
||||||
|
this.fail(res, 405, req.method + " " + req.url + " not supported.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("match", pat, req.url, match);
|
||||||
|
var args = [req, res];
|
||||||
|
match.slice(1).forEach(function (arg){ args.push(arg); });
|
||||||
|
handler.apply(handler, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no local route found, time to proxy
|
||||||
|
var target = this.target_for_url(req.url);
|
||||||
|
console.log("proxy " + req.url + " to " + target);
|
||||||
|
this.proxy.web(req, res, {
|
||||||
|
target: target
|
||||||
|
}, function (e) {
|
||||||
|
console.log("Proxy error: ", e);
|
||||||
|
res.writeHead(502);
|
||||||
|
res.write("Proxy target missing");
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.ConfigurableProxy = ConfigurableProxy;
|
18
login.html
Normal file
18
login.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if message %}
|
||||||
|
<div id="message">{{message}}</div>
|
||||||
|
{% end if %}
|
||||||
|
<form action="/login?next={{next}}" method="post">
|
||||||
|
<input type="text" name="user" id="user_input" value="{{user}}">
|
||||||
|
<input type="password" name="password" id="password_input">
|
||||||
|
<button type="submit" id="login_submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
186
multiuser.py
Normal file
186
multiuser.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from subprocess import Popen
|
||||||
|
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
import tornado.httpserver
|
||||||
|
import tornado.ioloop
|
||||||
|
import tornado.options
|
||||||
|
from tornado.log import app_log
|
||||||
|
from tornado.escape import url_escape
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
from tornado.web import RequestHandler, Application
|
||||||
|
from tornado import web
|
||||||
|
|
||||||
|
from tornado.options import define, options
|
||||||
|
|
||||||
|
def random_port():
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(('', 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
define("port", default=8001, help="run on the given port", type=int)
|
||||||
|
|
||||||
|
|
||||||
|
class SingleUserManager(object):
|
||||||
|
|
||||||
|
routes_t = 'http://localhost:8000/api/routes{uri}'
|
||||||
|
single_user_t = 'http://localhost:{port}'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processes = {}
|
||||||
|
self.ports = {}
|
||||||
|
|
||||||
|
def _wait_for_port(self, port, timeout=2):
|
||||||
|
tic = time.time()
|
||||||
|
while time.time() - tic < timeout:
|
||||||
|
try:
|
||||||
|
socket.create_connection(('localhost', port))
|
||||||
|
except socket.error:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
def spawn(self, user):
|
||||||
|
assert user not in self.processes
|
||||||
|
port = random_port()
|
||||||
|
self.processes[user] = Popen(
|
||||||
|
[sys.executable, 'singleuser.py', '--port=%i' % port, '--user=%s' % user])
|
||||||
|
self.ports[user] = port
|
||||||
|
r = requests.post(
|
||||||
|
self.routes_t.format(uri=u'/user/%s' % user),
|
||||||
|
data=json.dumps(dict(
|
||||||
|
target=self.single_user_t.format(port=port),
|
||||||
|
user=user,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
self._wait_for_port(port)
|
||||||
|
r.raise_for_status()
|
||||||
|
print("spawn done")
|
||||||
|
|
||||||
|
def exists(self, user):
|
||||||
|
if user in self.processes:
|
||||||
|
if self.processes[user].poll() is None:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.processes.pop(user)
|
||||||
|
self.ports.pop(user)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, user):
|
||||||
|
"""ensure process exists and return its port"""
|
||||||
|
if not self.exists(user):
|
||||||
|
self.spawn(user)
|
||||||
|
return self.ports[user]
|
||||||
|
|
||||||
|
def shutdown(self, user):
|
||||||
|
assert user in self.processes
|
||||||
|
port = self.ports[user]
|
||||||
|
self.processes[user].terminate()
|
||||||
|
r = requests.delete(self.routes_url,
|
||||||
|
data=json.dumps(user=user, port=port),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
class BaseHandler(RequestHandler):
|
||||||
|
cookie_name = 'multiusertest'
|
||||||
|
def get_current_user(self):
|
||||||
|
user = self.get_cookie(self.cookie_name, '')
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_manager(self):
|
||||||
|
return self.settings['user_manager']
|
||||||
|
|
||||||
|
def clear_login_cookie(self):
|
||||||
|
self.clear_cookie(self.cookie_name)
|
||||||
|
|
||||||
|
class MainHandler(BaseHandler):
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
self.redirect("/user/%s" % self.get_current_user())
|
||||||
|
|
||||||
|
class UserHandler(BaseHandler):
|
||||||
|
@web.authenticated
|
||||||
|
def get(self, user):
|
||||||
|
self.write("multi-user at single-user url: %s" % user)
|
||||||
|
if self.get_current_user() == user:
|
||||||
|
self.user_manager.spawn(user)
|
||||||
|
self.redirect('')
|
||||||
|
else:
|
||||||
|
self.clear_login_cookie()
|
||||||
|
self.redirect(url_concat(self.settings['login_url'], {
|
||||||
|
'next' : '/user/%s' % user
|
||||||
|
}))
|
||||||
|
|
||||||
|
class LogoutHandler(BaseHandler):
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
self.clear_login_cookie()
|
||||||
|
self.write("logged out")
|
||||||
|
|
||||||
|
class LoginHandler(BaseHandler):
|
||||||
|
|
||||||
|
def _render(self, message=None, user=None):
|
||||||
|
self.render('login.html',
|
||||||
|
next=url_escape(self.get_argument('next', default='')),
|
||||||
|
user=user,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
if self.get_current_user():
|
||||||
|
self.redirect(self.get_argument('next', default='/'))
|
||||||
|
else:
|
||||||
|
user = self.get_argument('user', default='')
|
||||||
|
self._render(user=user)
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
user = self.get_argument('user', default='')
|
||||||
|
pwd = self.get_argument('password', default=u'')
|
||||||
|
next_url = self.get_argument('next', default='') or '/user/%s' % user
|
||||||
|
if user and pwd == 'password':
|
||||||
|
self.set_cookie(self.cookie_name, user)
|
||||||
|
else:
|
||||||
|
self._render(
|
||||||
|
message={'error': 'Invalid username or password'},
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.redirect(next_url)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tornado.options.parse_command_line()
|
||||||
|
application = Application([
|
||||||
|
(r"/", MainHandler),
|
||||||
|
(r"/login", LoginHandler),
|
||||||
|
# (r"/shutdown/([^/]+)", ShutdownHandler),
|
||||||
|
# (r"/start/([^/]+)", StartHandler),
|
||||||
|
(r"/user/([^/]+)/?.*", UserHandler),
|
||||||
|
],
|
||||||
|
user_manager=SingleUserManager(),
|
||||||
|
cookie_secret='super secret',
|
||||||
|
login_url='/login',
|
||||||
|
)
|
||||||
|
http_server = tornado.httpserver.HTTPServer(application)
|
||||||
|
http_server.listen(options.port)
|
||||||
|
proxy = Popen(["node", "proxy.js"])
|
||||||
|
try:
|
||||||
|
tornado.ioloop.IOLoop.instance().start()
|
||||||
|
finally:
|
||||||
|
proxy.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
6
proxy.js
Normal file
6
proxy.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
var ConfigurableProxy = require('./lib/configproxy.js').ConfigurableProxy;
|
||||||
|
|
||||||
|
var proxy = new ConfigurableProxy();
|
||||||
|
proxy.listen(8000);
|
49
singleuser.py
Normal file
49
singleuser.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import tornado.httpserver
|
||||||
|
import tornado.ioloop
|
||||||
|
import tornado.options
|
||||||
|
from tornado.web import RequestHandler, Application
|
||||||
|
from tornado import web
|
||||||
|
from tornado.log import app_log
|
||||||
|
|
||||||
|
from tornado.options import define, options
|
||||||
|
|
||||||
|
define("port", default=8888, help="run on the given port", type=int)
|
||||||
|
define("user", default='', help="my username", type=str)
|
||||||
|
|
||||||
|
class BaseHandler(RequestHandler):
|
||||||
|
cookie_name = 'multiusertest'
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
return self.settings['user']
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
user = self.get_cookie(self.cookie_name, '')
|
||||||
|
if user and user == self.user:
|
||||||
|
return user
|
||||||
|
else:
|
||||||
|
raise web.HTTPError(403, "User %s does not have access to %s" % (user, self.user))
|
||||||
|
|
||||||
|
class MainHandler(BaseHandler):
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def get(self, uri):
|
||||||
|
self.write("single-user %s: %s" % (self.user, uri))
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tornado.options.parse_command_line()
|
||||||
|
application = Application([
|
||||||
|
(r"(.*)", MainHandler),
|
||||||
|
],
|
||||||
|
user=options.user,
|
||||||
|
login_url='/login',
|
||||||
|
)
|
||||||
|
http_server = tornado.httpserver.HTTPServer(application)
|
||||||
|
http_server.listen(options.port)
|
||||||
|
app_log.info("single user %s listening on %s" % (options.user, options.port))
|
||||||
|
tornado.ioloop.IOLoop.instance().start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Reference in New Issue
Block a user