From b54bfad8c2708d2f441ebab7a5c1e6087da225e8 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 22 Feb 2016 16:45:50 +0100 Subject: [PATCH 1/8] [WIP]: allow running single-user servers on subdomains relies on CHP's host-based routing (a feature I didn't add!) requires wildcard DNS and wildcard SSL for a proper setup still lots to workout and cleanup in terms of cookies and where to use host, domain, path, but it works locally. --- jupyterhub/apihandlers/base.py | 2 +- jupyterhub/app.py | 36 +++++++++++++++++++++++++++++++++- jupyterhub/handlers/base.py | 36 ++++++++++++++++++++++++++++++---- jupyterhub/handlers/pages.py | 8 ++++---- jupyterhub/orm.py | 6 +++--- jupyterhub/user.py | 33 +++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index ce422009..a63233b2 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -86,7 +86,7 @@ class APIHandler(BaseHandler): model = { 'name': user.name, 'admin': user.admin, - 'server': user.server.base_url if user.running else None, + 'server': user.url if user.running else None, 'pending': None, 'last_activity': user.last_activity.isoformat(), } diff --git a/jupyterhub/app.py b/jupyterhub/app.py index c606c07e..65cc7ce9 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -230,8 +230,20 @@ class JupyterHub(Application): """ ) ip = Unicode('', config=True, - help="The public facing ip of the proxy" + help="The public facing ip of the whole application (the proxy)" ) + + subdomain_host = Unicode('', config=True, + help="""The public-facing host (domain[:port]) on which the Hub will run. + + Only used when subdomains are involved. + """ + ) + def _subdomain_host_default(self): + # FIXME: use xip.io for debugging + return (self.ip or '127.0.0.1') + '.xip.io:%i' % self.port + + port = Integer(8000, config=True, help="The public facing port of the proxy" ) @@ -248,6 +260,18 @@ class JupyterHub(Application): help="Supply extra arguments that will be passed to Jinja environment." ) + use_subdomains = Bool(True, config=True, + help="""Run single-user servers on subdomains. + + Provides additional cross-site protections for client-side js. + + Requires .hub.domain.tld to resolve to the same host as hub.domain.tld. + + In general, this is most easily achieved with wildcard DNS. + + When using SSL (i.e. always) this also requires a wildcard cert. + """) + proxy_cmd = Command('configurable-http-proxy', config=True, help="""The command to start the http proxy. @@ -769,6 +793,9 @@ class JupyterHub(Application): ) self.db.add(self.proxy) self.db.commit() + if self.use_subdomains: + # assert not ip-address (self.ip) + assert self.subdomain_host self.proxy.auth_token = self.proxy_auth_token # not persisted self.proxy.log = self.log self.proxy.public_server.ip = self.ip @@ -809,6 +836,8 @@ class JupyterHub(Application): '--api-port', str(self.proxy.api_server.port), '--default-target', self.hub.server.host, ] + if self.use_subdomains: + cmd.append('--host-routing') if self.debug_proxy: cmd.extend(['--log-level', 'debug']) if self.ssl_key: @@ -893,6 +922,8 @@ class JupyterHub(Application): else: version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), + subdomain_host = self.subdomain_host + domain = subdomain_host.rsplit(':', 1)[0] settings = dict( log_function=log_request, config=self.config, @@ -915,6 +946,9 @@ class JupyterHub(Application): template_path=self.template_paths, jinja2_env=jinja_env, version_hash=version_hash, + use_subdomains=self.use_subdomains, + subdomain_host=subdomain_host, + domain=domain, ) # allow configured settings to have priority settings.update(self.tornado_settings) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index ea226114..5764a633 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -51,6 +51,14 @@ class BaseHandler(RequestHandler): def version_hash(self): return self.settings.get('version_hash', '') + @property + def use_subdomains(self): + return self.settings.get('use_subdomains', False) + + @property + def domain(self): + return self.settings['domain'] + @property def db(self): return self.settings['db'] @@ -199,18 +207,30 @@ class BaseHandler(RequestHandler): user = self.get_current_user() else: user = self.find_user(name) + kwargs = {} + if self.use_subdomains: + # is domain required here? Does clear without domain still clear it? + # set cookie for all subdomains + kwargs['domain'] = self.domain if user and user.server: - self.clear_cookie(user.server.cookie_name, path=user.server.base_url) - self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) + self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs) + self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs) def set_server_cookie(self, user): """set the login cookie for the single-user server""" # tornado <4.2 have a bug that consider secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie if self.request.protocol == 'https': - kwargs = {'secure':True} + kwargs = {'secure': True} else: kwargs = {} + if self.use_subdomains: + kwargs['domain'] = self.domain + if not self.request.host.startswith(self.domain): + self.log.warning( + "Possibly setting cookie on wrong domain: %s != %s", + self.request.host, self.domain) + self.log.debug("Setting cookie for %s: %s, %s", user.name, user.server.cookie_name, kwargs) self.set_secure_cookie( user.server.cookie_name, user.cookie_id, @@ -223,9 +243,15 @@ class BaseHandler(RequestHandler): # tornado <4.2 have a bug that consider secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie if self.request.protocol == 'https': - kwargs = {'secure':True} + kwargs = {'secure': True} else: kwargs = {} + if self.use_subdomains: + kwargs['domain'] = self.settings['domain'] + self.log.warning( + "Possibly setting cookie on wrong domain: %s != %s", + self.request.host, self.domain) + self.log.debug("Setting cookie for %s: %s, %s", user.name, self.hub.server.cookie_name, kwargs) self.set_secure_cookie( self.hub.server.cookie_name, user.cookie_id, @@ -465,6 +491,8 @@ class UserSpawnHandler(BaseHandler): self.set_login_cookie(current_user) without_prefix = self.request.uri[len(self.hub.server.base_url):] target = url_path_join(self.base_url, without_prefix) + if self.use_subdomains: + target = '//' + current_user.host + target self.redirect(target) else: # not logged in to the right user, diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index f1d5c73f..bdd9998f 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -25,7 +25,7 @@ class RootHandler(BaseHandler): user = self.get_current_user() if user: if user.running: - url = user.server.base_url + url = user.url self.log.debug("User is running: %s", url) else: url = url_path_join(self.hub.server.base_url, 'home') @@ -67,7 +67,7 @@ class SpawnHandler(BaseHandler): """GET renders form for spawning with user-specified options""" user = self.get_current_user() if user.running: - url = user.server.base_url + url = user.url self.log.debug("User is running: %s", url) self.redirect(url) return @@ -84,7 +84,7 @@ class SpawnHandler(BaseHandler): """POST spawns with user-specified options""" user = self.get_current_user() if user.running: - url = user.server.base_url + url = user.url self.log.warning("User is already running: %s", url) self.redirect(url) return @@ -101,7 +101,7 @@ class SpawnHandler(BaseHandler): self.finish(self._render_form(str(e))) return self.set_login_cookie(user) - url = user.server.base_url + url = user.url self.redirect(url) class AdminHandler(BaseHandler): diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 96327a15..ac394767 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -176,10 +176,10 @@ class Proxy(Base): def add_user(self, user, client=None): """Add a user's server to the proxy table.""" self.log.info("Adding user %s to proxy %s => %s", - user.name, user.server.base_url, user.server.host, + user.name, user.proxy_path, user.server.host, ) - yield self.api_request(user.server.base_url, + yield self.api_request(user.proxy_path, method='POST', body=dict( target=user.server.host, @@ -192,7 +192,7 @@ class Proxy(Base): def delete_user(self, user, client=None): """Remove a user's server to the proxy table.""" self.log.info("Removing user %s from proxy", user.name) - yield self.api_request(user.server.base_url, + yield self.api_request(user.proxy_path, method='DELETE', client=client, ) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 5b47d32b..a48bcf12 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -148,6 +148,39 @@ class User(HasTraits): """My name, escaped for use in URLs, cookies, etc.""" return quote(self.name, safe='') + @property + def proxy_path(self): + if self.settings.get('use_subdomains'): + return url_path_join('/' + self.domain, self.server.base_url) + else: + return self.server.base_url + + @property + def domain(self): + """Get the domain for my server.""" + # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment + return self.escaped_name + '.' + self.settings['domain'] + + @property + def host(self): + """Get the *host* for my server (domain[:port])""" + # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment + return self.escaped_name + '.' + self.settings['subdomain_host'] + + @property + def url(self): + """My URL + + Full name.domain/path if using subdomains, otherwise just my /base/url + """ + if self.settings.get('use_subdomains'): + return '//{host}{path}'.format( + host=self.host, + path=self.server.base_url, + ) + else: + return self.server.base_url + @gen.coroutine def spawn(self, options=None): """Start the user's spawner""" From a4ae2ec2d865f8085b9e3feb0519b157d4bdcd58 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 12:01:47 +0100 Subject: [PATCH 2/8] consolidate cookie setting in _set_user_cookie --- jupyterhub/handlers/base.py | 42 ++++++++++++------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 5764a633..9463254f 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -209,15 +209,12 @@ class BaseHandler(RequestHandler): user = self.find_user(name) kwargs = {} if self.use_subdomains: - # is domain required here? Does clear without domain still clear it? - # set cookie for all subdomains kwargs['domain'] = self.domain if user and user.server: self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs) self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs) - def set_server_cookie(self, user): - """set the login cookie for the single-user server""" + def _set_user_cookie(self, user, server): # tornado <4.2 have a bug that consider secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie if self.request.protocol == 'https': @@ -226,41 +223,28 @@ class BaseHandler(RequestHandler): kwargs = {} if self.use_subdomains: kwargs['domain'] = self.domain - if not self.request.host.startswith(self.domain): - self.log.warning( - "Possibly setting cookie on wrong domain: %s != %s", - self.request.host, self.domain) - self.log.debug("Setting cookie for %s: %s, %s", user.name, user.server.cookie_name, kwargs) + self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs) self.set_secure_cookie( - user.server.cookie_name, + server.cookie_name, user.cookie_id, - path=user.server.base_url, + path=server.base_url, **kwargs ) + def set_server_cookie(self, user): + """set the login cookie for the single-user server""" + self._set_user_cookie(user, user.server) + def set_hub_cookie(self, user): """set the login cookie for the Hub""" - # tornado <4.2 have a bug that consider secure==True as soon as - # 'secure' kwarg is passed to set_secure_cookie - if self.request.protocol == 'https': - kwargs = {'secure': True} - else: - kwargs = {} - if self.use_subdomains: - kwargs['domain'] = self.settings['domain'] - self.log.warning( - "Possibly setting cookie on wrong domain: %s != %s", - self.request.host, self.domain) - self.log.debug("Setting cookie for %s: %s, %s", user.name, self.hub.server.cookie_name, kwargs) - self.set_secure_cookie( - self.hub.server.cookie_name, - user.cookie_id, - path=self.hub.server.base_url, - **kwargs - ) + self._set_user_cookie(user, self.hub.server) def set_login_cookie(self, user): """Set login cookies for the Hub and single-user server.""" + if self.use_subdomains and not self.request.host.startswith(self.domain): + self.log.warning( + "Possibly setting cookie on wrong domain: %s != %s", + self.request.host, self.domain) # create and set a new cookie token for the single-user server if user.server: self.set_server_cookie(user) From a9b8542ec7e1a1c4749f67d4364312ec2b3e18c4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 13:40:35 +0100 Subject: [PATCH 3/8] pass hub's host to single-user servers via hub_host --- jupyterhub/app.py | 16 ++++++++-------- jupyterhub/orm.py | 1 + jupyterhub/spawner.py | 1 + scripts/jupyterhub-singleuser | 16 ++++++++++------ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 65cc7ce9..d4525646 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -239,10 +239,6 @@ class JupyterHub(Application): Only used when subdomains are involved. """ ) - def _subdomain_host_default(self): - # FIXME: use xip.io for debugging - return (self.ip or '127.0.0.1') + '.xip.io:%i' % self.port - port = Integer(8000, config=True, help="The public facing port of the proxy" @@ -312,7 +308,6 @@ class JupyterHub(Application): hub_ip = Unicode('127.0.0.1', config=True, help="The ip for this process" ) - hub_prefix = URLPrefix('/hub/', config=True, help="The prefix for the hub server. Must not be '/'" ) @@ -598,11 +593,15 @@ class JupyterHub(Application): q = self.db.query(orm.Hub) assert q.count() <= 1 self._local.hub = q.first() + if self.use_subdomains: + self._local.hub.host = self.subdomain_host return self._local.hub @hub.setter def hub(self, hub): self._local.hub = hub + if self.use_subdomains: + hub.host = self.subdomain_host @property def proxy(self): @@ -655,6 +654,10 @@ class JupyterHub(Application): server.ip = self.hub_ip server.port = self.hub_port server.base_url = self.hub_prefix + if self.use_subdomains: + if not self.subdomain_host: + raise ValueError("Must specify subdomain_host when using subdomains." + " This should be the public domain[:port] of the Hub.") self.db.commit() @@ -793,9 +796,6 @@ class JupyterHub(Application): ) self.db.add(self.proxy) self.db.commit() - if self.use_subdomains: - # assert not ip-address (self.ip) - assert self.subdomain_host self.proxy.auth_token = self.proxy_auth_token # not persisted self.proxy.log = self.log self.proxy.public_server.ip = self.ip diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index ac394767..bd824336 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -253,6 +253,7 @@ class Hub(Base): id = Column(Integer, primary_key=True) _server_id = Column(Integer, ForeignKey('servers.id')) server = relationship(Server, primaryjoin=_server_id == Server.id) + host = '' @property def api_url(self): diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 14b5735a..fb6a7075 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -199,6 +199,7 @@ class Spawner(LoggingConfigurable): '--port=%i' % self.user.server.port, '--cookie-name=%s' % self.user.server.cookie_name, '--base-url=%s' % self.user.server.base_url, + '--hub-host=%s' % ('//' + self.hub.host) if self.hub.host else '', '--hub-prefix=%s' % self.hub.server.base_url, '--hub-api-url=%s' % self.hub.api_url, ] diff --git a/scripts/jupyterhub-singleuser b/scripts/jupyterhub-singleuser index e82ac6ad..4ec76ed0 100755 --- a/scripts/jupyterhub-singleuser +++ b/scripts/jupyterhub-singleuser @@ -104,7 +104,9 @@ class JupyterHubLoginHandler(LoginHandler): class JupyterHubLogoutHandler(LogoutHandler): def get(self): - self.redirect(url_path_join(self.settings['hub_prefix'], 'logout')) + self.redirect( + self.settings['hub_host'] + + url_path_join(self.settings['hub_prefix'], 'logout')) # register new hub related command-line aliases @@ -113,6 +115,7 @@ aliases.update({ 'user' : 'SingleUserNotebookApp.user', 'cookie-name': 'SingleUserNotebookApp.cookie_name', 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', + 'hub-host': 'SingleUserNotebookApp.hub_host', 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', 'base-url': 'SingleUserNotebookApp.base_url', }) @@ -141,6 +144,7 @@ class SingleUserNotebookApp(NotebookApp): self.log.name = new cookie_name = Unicode(config=True) hub_prefix = Unicode(config=True) + hub_host = Unicode(config=True) hub_api_url = Unicode(config=True) aliases = aliases open_browser = False @@ -194,22 +198,22 @@ class SingleUserNotebookApp(NotebookApp): s['user'] = self.user s['hub_api_key'] = env.pop('JPY_API_TOKEN') s['hub_prefix'] = self.hub_prefix + s['hub_host'] = self.hub_host s['cookie_name'] = self.cookie_name - s['login_url'] = self.hub_prefix + s['login_url'] = self.hub_host + self.hub_prefix s['hub_api_url'] = self.hub_api_url - s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report') - + s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report') super(SingleUserNotebookApp, self).init_webapp() self.patch_templates() def patch_templates(self): """Patch page templates to add Hub-related buttons""" - self.jinja_template_vars['logo_url'] = url_path_join(self.hub_prefix, 'logo') + self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo') env = self.web_app.settings['jinja2_env'] env.globals['hub_control_panel_url'] = \ - url_path_join(self.hub_prefix, 'home') + self.hub_host + url_path_join(self.hub_prefix, 'home') # patch jinja env loading to modify page template def get_page(name): From dd2e1ef758e2e5b0203bab65c5448d6ed6e73364 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 13:40:41 +0100 Subject: [PATCH 4/8] turn off subdomains by default --- jupyterhub/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index d4525646..ba475e6f 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -256,7 +256,7 @@ class JupyterHub(Application): help="Supply extra arguments that will be passed to Jinja environment." ) - use_subdomains = Bool(True, config=True, + use_subdomains = Bool(False, config=True, help="""Run single-user servers on subdomains. Provides additional cross-site protections for client-side js. From 29416463ff12dfdd04271eff893ad5146ea31e58 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 13:53:11 +0100 Subject: [PATCH 5/8] proxy needs user dict, which has proxy path this won't be needed if/when I make a schema change, where domain is included in the Server table. --- jupyterhub/apihandlers/proxy.py | 4 ++-- jupyterhub/app.py | 6 +++--- jupyterhub/orm.py | 24 +++++++++++++----------- jupyterhub/tests/test_proxy.py | 5 +++-- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/jupyterhub/apihandlers/proxy.py b/jupyterhub/apihandlers/proxy.py index 4d17b8d9..54d75500 100644 --- a/jupyterhub/apihandlers/proxy.py +++ b/jupyterhub/apihandlers/proxy.py @@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler): @gen.coroutine def post(self): """POST checks the proxy to ensure""" - yield self.proxy.check_routes() + yield self.proxy.check_routes(self.users) @admin_only @@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler): self.proxy.auth_token = model['auth_token'] self.db.commit() self.log.info("Updated proxy at %s", server.bind_url) - yield self.proxy.check_routes() + yield self.proxy.check_routes(self.users) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index ba475e6f..eab4e278 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -896,7 +896,7 @@ class JupyterHub(Application): ) yield self.start_proxy() self.log.info("Setting up routes on new proxy") - yield self.proxy.add_all_users() + yield self.proxy.add_all_users(self.users) self.log.info("New proxy back up, and good to go") def init_tornado_settings(self): @@ -1085,7 +1085,7 @@ class JupyterHub(Application): user.last_activity = max(user.last_activity, dt) self.db.commit() - yield self.proxy.check_routes(routes) + yield self.proxy.check_routes(routes, self.users) @gen.coroutine def start(self): @@ -1120,7 +1120,7 @@ class JupyterHub(Application): self.exit(1) return - loop.add_callback(self.proxy.add_all_users) + loop.add_callback(self.proxy.add_all_users, self.users) if self.proxy_process: # only check / restart the proxy if we started it in the first place. diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index bd824336..2a61b937 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -198,14 +198,21 @@ class Proxy(Base): ) @gen.coroutine - def add_all_users(self): + def get_routes(self, client=None): + """Fetch the proxy's routes""" + resp = yield self.api_request('', client=client) + return json.loads(resp.body.decode('utf8', 'replace')) + + @gen.coroutine + def add_all_users(self, user_dict): """Update the proxy table from the database. Used when loading up a new proxy. """ db = inspect(self).session futures = [] - for user in db.query(User): + for orm_user in db.query(User): + user = user_dict[orm_user] if (user.server): futures.append(self.add_user(user)) # wait after submitting them all @@ -213,21 +220,16 @@ class Proxy(Base): yield f @gen.coroutine - def get_routes(self, client=None): - """Fetch the proxy's routes""" - resp = yield self.api_request('', client=client) - return json.loads(resp.body.decode('utf8', 'replace')) - - @gen.coroutine - def check_routes(self, routes=None): - """Check that all users are properly""" + def check_routes(self, user_dict, routes=None): + """Check that all users are properly routed on the proxy""" if not routes: routes = yield self.get_routes() have_routes = { r['user'] for r in routes.values() if 'user' in r } futures = [] db = inspect(self).session - for user in db.query(User).filter(User.server != None): + for orm_user in db.query(User).filter(User.server != None): + user = user_dict[orm_user] if user.server is None: # This should never be True, but seems to be on rare occasion. # catch filter bug, either in sqlalchemy or my understanding of its behavior diff --git a/jupyterhub/tests/test_proxy.py b/jupyterhub/tests/test_proxy.py index c5fc5a97..786c7c23 100644 --- a/jupyterhub/tests/test_proxy.py +++ b/jupyterhub/tests/test_proxy.py @@ -123,13 +123,14 @@ def test_check_routes(app, io_loop): r.raise_for_status() zoe = orm.User.find(app.db, 'zoe') assert zoe is not None + zoe = app.users[zoe] before = sorted(io_loop.run_sync(app.proxy.get_routes)) assert '/user/zoe' in before - io_loop.run_sync(app.proxy.check_routes) + io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) io_loop.run_sync(lambda : proxy.delete_user(zoe)) during = sorted(io_loop.run_sync(app.proxy.get_routes)) assert '/user/zoe' not in during - io_loop.run_sync(app.proxy.check_routes) + io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) after = sorted(io_loop.run_sync(app.proxy.get_routes)) assert '/user/zoe' in after assert before == after From 79df83f0d3e39ad19191b250d79eb496d84577a4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 17:32:46 +0100 Subject: [PATCH 6/8] Allow getting users by name --- jupyterhub/user.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index a48bcf12..bfd11943 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -38,6 +38,12 @@ class UserDict(dict): def __getitem__(self, key): if isinstance(key, User): key = key.id + elif isinstance(key, str): + orm_user = self.db.query(orm.User).filter(orm.User.name==key).first() + if orm_user is None: + raise KeyError("No such user: %s" % name) + else: + key = orm_user if isinstance(key, orm.User): # users[orm_user] returns User(orm_user) orm_user = key From f9225610034e23dd8761f1afa892c89e129f3f2d Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 17:32:38 +0100 Subject: [PATCH 7/8] Tests are passing with subdomains --- jupyterhub/app.py | 4 ++-- jupyterhub/tests/mocking.py | 36 +++++++++++++++++++++++++++++++--- jupyterhub/tests/test_api.py | 12 ++++++++---- jupyterhub/tests/test_pages.py | 20 +++++++++---------- jupyterhub/tests/test_proxy.py | 21 +++++++++++++------- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index eab4e278..038daf95 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -593,14 +593,14 @@ class JupyterHub(Application): q = self.db.query(orm.Hub) assert q.count() <= 1 self._local.hub = q.first() - if self.use_subdomains: + if self.use_subdomains and self._local.hub: self._local.hub.host = self.subdomain_host return self._local.hub @hub.setter def hub(self, hub): self._local.hub = hub - if self.use_subdomains: + if hub and self.use_subdomains: hub.host = self.subdomain_host @property diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 0b77fc73..9998e5a0 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -1,7 +1,7 @@ """mock utilities for testing""" +import os import sys -from datetime import timedelta from tempfile import NamedTemporaryFile import threading @@ -13,10 +13,11 @@ from tornado import gen from tornado.concurrent import Future from tornado.ioloop import IOLoop -from ..spawner import LocalProcessSpawner from ..app import JupyterHub from ..auth import PAMAuthenticator from .. import orm +from ..spawner import LocalProcessSpawner +from ..utils import url_path_join from pamela import PAMError @@ -110,6 +111,12 @@ class MockHub(JupyterHub): db_file = None confirm_no_ssl = True + def _subdomain_host_default(self): + return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '') + + def _use_subdomains_default(self): + return bool(self.subdomain_host) + def _ip_default(self): return '127.0.0.1' @@ -161,7 +168,11 @@ class MockHub(JupyterHub): self.db_file.close() def login_user(self, name): - r = requests.post(self.proxy.public_server.url + 'hub/login', + if self.subdomain_host: + base_url = 'http://' + self.subdomain_host + self.proxy.public_server.base_url + else: + base_url = self.proxy.public_server.url + r = requests.post(base_url + 'hub/login', data={ 'username': name, 'password': name, @@ -171,3 +182,22 @@ class MockHub(JupyterHub): assert r.cookies return r.cookies + +def public_host(app): + if app.use_subdomains: + return app.subdomain_host + else: + return app.proxy.public_server.host + + +def public_url(app): + return 'http://%s%s' % (public_host(app), app.proxy.public_server.base_url) + + +def user_url(user, app): + print(user.host) + if app.use_subdomains: + host = user.host + else: + host = public_host(app) + return url_path_join('http://%s' % host, user.server.base_url) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 413ff913..021db510 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -2,7 +2,6 @@ import json import time -from datetime import timedelta from queue import Queue from urllib.parse import urlparse @@ -14,6 +13,7 @@ from .. import orm from ..user import User from ..utils import url_path_join as ujoin from . import mocking +from .mocking import public_url, user_url def check_db_locks(func): @@ -105,7 +105,7 @@ def test_auth_api(app): def test_referer_check(app, io_loop): - url = app.hub.server.url + url = ujoin(public_url(app), app.hub.server.base_url) host = urlparse(url).netloc user = find_user(app.db, 'admin') if user is None: @@ -352,15 +352,19 @@ def test_spawn(app, io_loop): assert status is None assert user.server.base_url == '/user/%s' % name - r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url)) + url = user_url(user, app) + print(url) + r = requests.get(url) assert r.status_code == 200 assert r.text == user.server.base_url - r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args')) + r = requests.get(ujoin(url, 'args')) assert r.status_code == 200 argv = r.json() for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]: assert expected in argv + if app.use_subdomains: + assert '--hub-host=//%s' % app.subdomain_host in argv r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 204 diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index ba85ff26..d1bb8504 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -8,12 +8,11 @@ from ..utils import url_path_join as ujoin from .. import orm import mock -from .mocking import FormSpawner +from .mocking import FormSpawner, public_url, public_host, user_url from .test_api import api_request - def get_page(path, app, **kw): - base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url) + base_url = ujoin(public_url(app), app.hub.server.base_url) print(base_url) return requests.get(ujoin(base_url, path), **kw) @@ -22,15 +21,16 @@ def test_root_no_auth(app, io_loop): routes = io_loop.run_sync(app.proxy.get_routes) print(routes) print(app.hub.server) - r = requests.get(app.proxy.public_server.host) + url = public_url(app) + r = requests.get(url) r.raise_for_status() - assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url, 'login') + assert r.url == ujoin(url, app.hub.server.base_url, 'login') def test_root_auth(app): cookies = app.login_user('river') - r = requests.get(app.proxy.public_server.host, cookies=cookies) + r = requests.get(public_url(app), cookies=cookies) r.raise_for_status() - assert r.url == ujoin(app.proxy.public_server.host, '/user/river') + assert r.url == user_url(app.users['river'], app) def test_home_no_auth(app): r = get_page('home', app, allow_redirects=False) @@ -100,7 +100,7 @@ def test_spawn_page(app): def test_spawn_form(app, io_loop): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): - base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url) + base_url = ujoin(public_url(app), app.hub.server.base_url) cookies = app.login_user('jones') orm_u = orm.User.find(app.db, 'jones') u = app.users[orm_u] @@ -121,7 +121,7 @@ def test_spawn_form(app, io_loop): def test_spawn_form_with_file(app, io_loop): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): - base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url) + base_url = ujoin(public_url(app), app.hub.server.base_url) cookies = app.login_user('jones') orm_u = orm.User.find(app.db, 'jones') u = app.users[orm_u] @@ -149,7 +149,7 @@ def test_spawn_form_with_file(app, io_loop): def test_static_files(app): - base_url = ujoin(app.proxy.public_server.url, app.hub.server.base_url) + base_url = ujoin(public_url(app), app.hub.server.base_url) print(base_url) r = requests.get(ujoin(base_url, 'logo')) r.raise_for_status() diff --git a/jupyterhub/tests/test_proxy.py b/jupyterhub/tests/test_proxy.py index 786c7c23..ef4cac6e 100644 --- a/jupyterhub/tests/test_proxy.py +++ b/jupyterhub/tests/test_proxy.py @@ -34,6 +34,8 @@ def test_external_proxy(request, io_loop): '--api-port', str(proxy_port), '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), ] + if app.use_subdomains: + cmd.append('--host-routing') proxy = Popen(cmd, env=env) def _cleanup_proxy(): if proxy.poll() is None: @@ -60,7 +62,11 @@ def test_external_proxy(request, io_loop): r.raise_for_status() routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + user_path = '/user/river' + if app.use_subdomains: + domain = app.subdomain_host.rsplit(':', 1)[0] + user_path = '/%s.%s' % (name, domain) + user_path + assert sorted(routes.keys()) == ['/', user_path] # teardown the proxy and start a new one in the same place proxy.terminate() @@ -76,7 +82,7 @@ def test_external_proxy(request, io_loop): # check that the routes are correct routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + assert sorted(routes.keys()) == ['/', user_path] # teardown the proxy again, and start a new one with different auth and port proxy.terminate() @@ -90,7 +96,8 @@ def test_external_proxy(request, io_loop): '--api-port', str(proxy_port), '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), ] - + if app.use_subdomains: + cmd.append('--host-routing') proxy = Popen(cmd, env=env) wait_for_proxy() @@ -113,7 +120,7 @@ def test_external_proxy(request, io_loop): # check that the routes are correct routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + assert sorted(routes.keys()) == ['/', user_path] def test_check_routes(app, io_loop): proxy = app.proxy @@ -125,12 +132,12 @@ def test_check_routes(app, io_loop): assert zoe is not None zoe = app.users[zoe] before = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' in before + assert zoe.proxy_path in before io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) io_loop.run_sync(lambda : proxy.delete_user(zoe)) during = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' not in during + assert zoe.proxy_path not in during io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) after = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' in after + assert zoe.proxy_path in after assert before == after From 335b47d7c14b4bef02b5e6a8d54e4c8d8abc0726 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 26 Feb 2016 17:52:31 +0100 Subject: [PATCH 8/8] include protocol in subdomain_host makes everything easier, and tests are passing with and without subdomains (yay!) --- .travis.yml | 4 ++++ jupyterhub/app.py | 8 +++++++- jupyterhub/handlers/base.py | 2 +- jupyterhub/spawner.py | 2 +- jupyterhub/tests/mocking.py | 10 +++------- jupyterhub/tests/test_api.py | 2 +- jupyterhub/tests/test_pages.py | 1 + jupyterhub/tests/test_proxy.py | 3 ++- jupyterhub/user.py | 8 +++++--- 9 files changed, 25 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 09ef823f..1a904b89 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,3 +15,7 @@ script: - py.test --cov jupyterhub jupyterhub/tests -v after_success: - codecov +matrix: + include: + - python: 3.5 + env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000 diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 038daf95..ec944acd 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -16,6 +16,7 @@ from datetime import datetime from distutils.version import LooseVersion as V from getpass import getuser from subprocess import Popen +from urllib.parse import urlparse if sys.version_info[:2] < (3,3): raise ValueError("Python < 3.3 not supported: %s" % sys.version) @@ -239,6 +240,11 @@ class JupyterHub(Application): Only used when subdomains are involved. """ ) + def _subdomain_host_changed(self, name, old, new): + if new and '://' not in new: + # host should include '://' + # if not specified, assume https: You have to be really explicit about HTTP! + self.subdomain_host = 'https://' + new port = Integer(8000, config=True, help="The public facing port of the proxy" @@ -923,7 +929,7 @@ class JupyterHub(Application): version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), subdomain_host = self.subdomain_host - domain = subdomain_host.rsplit(':', 1)[0] + domain = urlparse(subdomain_host).hostname settings = dict( log_function=log_request, config=self.config, diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 9463254f..8f90c28a 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -476,7 +476,7 @@ class UserSpawnHandler(BaseHandler): without_prefix = self.request.uri[len(self.hub.server.base_url):] target = url_path_join(self.base_url, without_prefix) if self.use_subdomains: - target = '//' + current_user.host + target + target = current_user.host + target self.redirect(target) else: # not logged in to the right user, diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index fb6a7075..b44fd434 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -199,7 +199,7 @@ class Spawner(LoggingConfigurable): '--port=%i' % self.user.server.port, '--cookie-name=%s' % self.user.server.cookie_name, '--base-url=%s' % self.user.server.base_url, - '--hub-host=%s' % ('//' + self.hub.host) if self.hub.host else '', + '--hub-host=%s' % self.hub.host, '--hub-prefix=%s' % self.hub.server.base_url, '--hub-api-url=%s' % self.hub.api_url, ] diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 9998e5a0..0d2f6bef 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -168,10 +168,7 @@ class MockHub(JupyterHub): self.db_file.close() def login_user(self, name): - if self.subdomain_host: - base_url = 'http://' + self.subdomain_host + self.proxy.public_server.base_url - else: - base_url = self.proxy.public_server.url + base_url = public_url(self) r = requests.post(base_url + 'hub/login', data={ 'username': name, @@ -191,13 +188,12 @@ def public_host(app): def public_url(app): - return 'http://%s%s' % (public_host(app), app.proxy.public_server.base_url) + return public_host(app) + app.proxy.public_server.base_url def user_url(user, app): - print(user.host) if app.use_subdomains: host = user.host else: host = public_host(app) - return url_path_join('http://%s' % host, user.server.base_url) + return host + user.server.base_url diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 021db510..db2c2a44 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -364,7 +364,7 @@ def test_spawn(app, io_loop): for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]: assert expected in argv if app.use_subdomains: - assert '--hub-host=//%s' % app.subdomain_host in argv + assert '--hub-host=%s' % app.subdomain_host in argv r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 204 diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index d1bb8504..607e825e 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -22,6 +22,7 @@ def test_root_no_auth(app, io_loop): print(routes) print(app.hub.server) url = public_url(app) + print(url) r = requests.get(url) r.raise_for_status() assert r.url == ujoin(url, app.hub.server.base_url, 'login') diff --git a/jupyterhub/tests/test_proxy.py b/jupyterhub/tests/test_proxy.py index ef4cac6e..5245da37 100644 --- a/jupyterhub/tests/test_proxy.py +++ b/jupyterhub/tests/test_proxy.py @@ -4,6 +4,7 @@ import json import os from queue import Queue from subprocess import Popen +from urllib.parse import urlparse from .. import orm from .mocking import MockHub @@ -64,7 +65,7 @@ def test_external_proxy(request, io_loop): routes = io_loop.run_sync(app.proxy.get_routes) user_path = '/user/river' if app.use_subdomains: - domain = app.subdomain_host.rsplit(':', 1)[0] + domain = urlparse(app.subdomain_host).hostname user_path = '/%s.%s' % (name, domain) + user_path assert sorted(routes.keys()) == ['/', user_path] diff --git a/jupyterhub/user.py b/jupyterhub/user.py index bfd11943..666a57c8 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -2,7 +2,7 @@ # Distributed under the terms of the Modified BSD License. from datetime import datetime, timedelta -from urllib.parse import quote +from urllib.parse import quote, urlparse from tornado import gen from tornado.log import app_log @@ -171,7 +171,9 @@ class User(HasTraits): def host(self): """Get the *host* for my server (domain[:port])""" # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment - return self.escaped_name + '.' + self.settings['subdomain_host'] + parsed = urlparse(self.settings['subdomain_host']) + h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc) + return h @property def url(self): @@ -180,7 +182,7 @@ class User(HasTraits): Full name.domain/path if using subdomains, otherwise just my /base/url """ if self.settings.get('use_subdomains'): - return '//{host}{path}'.format( + return '{host}{path}'.format( host=self.host, path=self.server.base_url, )