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/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/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 c606c07e..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) @@ -230,8 +231,21 @@ 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_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" ) @@ -248,6 +262,18 @@ class JupyterHub(Application): help="Supply extra arguments that will be passed to Jinja environment." ) + use_subdomains = Bool(False, 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. @@ -288,7 +314,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 '/'" ) @@ -574,11 +599,15 @@ class JupyterHub(Application): q = self.db.query(orm.Hub) assert q.count() <= 1 self._local.hub = q.first() + 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 hub and self.use_subdomains: + hub.host = self.subdomain_host @property def proxy(self): @@ -631,6 +660,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() @@ -809,6 +842,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: @@ -867,7 +902,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): @@ -893,6 +928,8 @@ class JupyterHub(Application): else: version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), + subdomain_host = self.subdomain_host + domain = urlparse(subdomain_host).hostname settings = dict( log_function=log_request, config=self.config, @@ -915,6 +952,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) @@ -1051,7 +1091,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): @@ -1086,7 +1126,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/handlers/base.py b/jupyterhub/handlers/base.py index 3759e037..376b1c9d 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,42 +207,44 @@ class BaseHandler(RequestHandler): user = self.get_current_user() else: user = self.find_user(name) + kwargs = {} + if self.use_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_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': + kwargs = {'secure': True} + else: + kwargs = {} + if self.use_subdomains: + kwargs['domain'] = self.domain + self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs) + self.set_secure_cookie( + server.cookie_name, + user.cookie_id, + path=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} - else: - kwargs = {} - self.set_secure_cookie( - user.server.cookie_name, - user.cookie_id, - path=user.server.base_url, - **kwargs - ) + 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 = {} - 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) @@ -472,6 +482,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 6575d1a8..e82490c4 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..2a61b937 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,26 +192,11 @@ 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, ) - @gen.coroutine - def add_all_users(self): - """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): - if (user.server): - futures.append(self.add_user(user)) - # wait after submitting them all - for f in futures: - yield f - @gen.coroutine def get_routes(self, client=None): """Fetch the proxy's routes""" @@ -219,15 +204,32 @@ class Proxy(Base): return json.loads(resp.body.decode('utf8', 'replace')) @gen.coroutine - def check_routes(self, routes=None): - """Check that all users are properly""" + 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 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 + for f in futures: + yield f + + @gen.coroutine + 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 @@ -253,6 +255,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..b44fd434 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, '--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 0b77fc73..0d2f6bef 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,8 @@ class MockHub(JupyterHub): self.db_file.close() def login_user(self, name): - r = requests.post(self.proxy.public_server.url + 'hub/login', + base_url = public_url(self) + r = requests.post(base_url + 'hub/login', data={ 'username': name, 'password': name, @@ -171,3 +179,21 @@ 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 public_host(app) + app.proxy.public_server.base_url + + +def user_url(user, app): + if app.use_subdomains: + host = user.host + else: + host = public_host(app) + return host + user.server.base_url diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 413ff913..db2c2a44 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..607e825e 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,17 @@ 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) + print(url) + 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 +101,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 +122,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 +150,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 c5fc5a97..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 @@ -34,6 +35,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 +63,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 = urlparse(app.subdomain_host).hostname + 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 +83,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 +97,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 +121,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 @@ -123,13 +131,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) + 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 - io_loop.run_sync(app.proxy.check_routes) + 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 diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 7d963e04..fafd67ff 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 @@ -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 @@ -148,6 +154,41 @@ 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 + parsed = urlparse(self.settings['subdomain_host']) + h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc) + return h + + @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""" 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):