Merge pull request #436 from minrk/subdomains

allow running single-user servers on subdomains
This commit is contained in:
Kyle Kelley
2016-02-28 09:49:45 -06:00
14 changed files with 237 additions and 92 deletions

View File

@@ -15,3 +15,7 @@ script:
- py.test --cov jupyterhub jupyterhub/tests -v - py.test --cov jupyterhub jupyterhub/tests -v
after_success: after_success:
- codecov - codecov
matrix:
include:
- python: 3.5
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000

View File

@@ -86,7 +86,7 @@ class APIHandler(BaseHandler):
model = { model = {
'name': user.name, 'name': user.name,
'admin': user.admin, 'admin': user.admin,
'server': user.server.base_url if user.running else None, 'server': user.url if user.running else None,
'pending': None, 'pending': None,
'last_activity': user.last_activity.isoformat(), 'last_activity': user.last_activity.isoformat(),
} }

View File

@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
@gen.coroutine @gen.coroutine
def post(self): def post(self):
"""POST checks the proxy to ensure""" """POST checks the proxy to ensure"""
yield self.proxy.check_routes() yield self.proxy.check_routes(self.users)
@admin_only @admin_only
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
self.proxy.auth_token = model['auth_token'] self.proxy.auth_token = model['auth_token']
self.db.commit() self.db.commit()
self.log.info("Updated proxy at %s", server.bind_url) self.log.info("Updated proxy at %s", server.bind_url)
yield self.proxy.check_routes() yield self.proxy.check_routes(self.users)

View File

@@ -16,6 +16,7 @@ from datetime import datetime
from distutils.version import LooseVersion as V from distutils.version import LooseVersion as V
from getpass import getuser from getpass import getuser
from subprocess import Popen from subprocess import Popen
from urllib.parse import urlparse
if sys.version_info[:2] < (3,3): if sys.version_info[:2] < (3,3):
raise ValueError("Python < 3.3 not supported: %s" % sys.version) raise ValueError("Python < 3.3 not supported: %s" % sys.version)
@@ -230,8 +231,21 @@ class JupyterHub(Application):
""" """
) )
ip = Unicode('', config=True, 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, port = Integer(8000, config=True,
help="The public facing port of the proxy" 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." 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 <username>.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, proxy_cmd = Command('configurable-http-proxy', config=True,
help="""The command to start the http proxy. help="""The command to start the http proxy.
@@ -288,7 +314,6 @@ class JupyterHub(Application):
hub_ip = Unicode('127.0.0.1', config=True, hub_ip = Unicode('127.0.0.1', config=True,
help="The ip for this process" help="The ip for this process"
) )
hub_prefix = URLPrefix('/hub/', config=True, hub_prefix = URLPrefix('/hub/', config=True,
help="The prefix for the hub server. Must not be '/'" help="The prefix for the hub server. Must not be '/'"
) )
@@ -574,11 +599,15 @@ class JupyterHub(Application):
q = self.db.query(orm.Hub) q = self.db.query(orm.Hub)
assert q.count() <= 1 assert q.count() <= 1
self._local.hub = q.first() self._local.hub = q.first()
if self.use_subdomains and self._local.hub:
self._local.hub.host = self.subdomain_host
return self._local.hub return self._local.hub
@hub.setter @hub.setter
def hub(self, hub): def hub(self, hub):
self._local.hub = hub self._local.hub = hub
if hub and self.use_subdomains:
hub.host = self.subdomain_host
@property @property
def proxy(self): def proxy(self):
@@ -631,6 +660,10 @@ class JupyterHub(Application):
server.ip = self.hub_ip server.ip = self.hub_ip
server.port = self.hub_port server.port = self.hub_port
server.base_url = self.hub_prefix 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() self.db.commit()
@@ -809,6 +842,8 @@ class JupyterHub(Application):
'--api-port', str(self.proxy.api_server.port), '--api-port', str(self.proxy.api_server.port),
'--default-target', self.hub.server.host, '--default-target', self.hub.server.host,
] ]
if self.use_subdomains:
cmd.append('--host-routing')
if self.debug_proxy: if self.debug_proxy:
cmd.extend(['--log-level', 'debug']) cmd.extend(['--log-level', 'debug'])
if self.ssl_key: if self.ssl_key:
@@ -867,7 +902,7 @@ class JupyterHub(Application):
) )
yield self.start_proxy() yield self.start_proxy()
self.log.info("Setting up routes on new 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") self.log.info("New proxy back up, and good to go")
def init_tornado_settings(self): def init_tornado_settings(self):
@@ -893,6 +928,8 @@ class JupyterHub(Application):
else: else:
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
subdomain_host = self.subdomain_host
domain = urlparse(subdomain_host).hostname
settings = dict( settings = dict(
log_function=log_request, log_function=log_request,
config=self.config, config=self.config,
@@ -915,6 +952,9 @@ class JupyterHub(Application):
template_path=self.template_paths, template_path=self.template_paths,
jinja2_env=jinja_env, jinja2_env=jinja_env,
version_hash=version_hash, version_hash=version_hash,
use_subdomains=self.use_subdomains,
subdomain_host=subdomain_host,
domain=domain,
) )
# allow configured settings to have priority # allow configured settings to have priority
settings.update(self.tornado_settings) settings.update(self.tornado_settings)
@@ -1051,7 +1091,7 @@ class JupyterHub(Application):
user.last_activity = max(user.last_activity, dt) user.last_activity = max(user.last_activity, dt)
self.db.commit() self.db.commit()
yield self.proxy.check_routes(routes) yield self.proxy.check_routes(routes, self.users)
@gen.coroutine @gen.coroutine
def start(self): def start(self):
@@ -1086,7 +1126,7 @@ class JupyterHub(Application):
self.exit(1) self.exit(1)
return return
loop.add_callback(self.proxy.add_all_users) loop.add_callback(self.proxy.add_all_users, self.users)
if self.proxy_process: if self.proxy_process:
# only check / restart the proxy if we started it in the first place. # only check / restart the proxy if we started it in the first place.

View File

@@ -51,6 +51,14 @@ class BaseHandler(RequestHandler):
def version_hash(self): def version_hash(self):
return self.settings.get('version_hash', '') 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 @property
def db(self): def db(self):
return self.settings['db'] return self.settings['db']
@@ -199,42 +207,44 @@ class BaseHandler(RequestHandler):
user = self.get_current_user() user = self.get_current_user()
else: else:
user = self.find_user(name) user = self.find_user(name)
kwargs = {}
if self.use_subdomains:
kwargs['domain'] = self.domain
if user and user.server: if user and user.server:
self.clear_cookie(user.server.cookie_name, path=user.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) 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): def set_server_cookie(self, user):
"""set the login cookie for the single-user server""" """set the login cookie for the single-user server"""
# tornado <4.2 have a bug that consider secure==True as soon as self._set_user_cookie(user, user.server)
# '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
)
def set_hub_cookie(self, user): def set_hub_cookie(self, user):
"""set the login cookie for the Hub""" """set the login cookie for the Hub"""
# tornado <4.2 have a bug that consider secure==True as soon as self._set_user_cookie(user, self.hub.server)
# '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
)
def set_login_cookie(self, user): def set_login_cookie(self, user):
"""Set login cookies for the Hub and single-user server.""" """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 # create and set a new cookie token for the single-user server
if user.server: if user.server:
self.set_server_cookie(user) self.set_server_cookie(user)
@@ -472,6 +482,8 @@ class UserSpawnHandler(BaseHandler):
self.set_login_cookie(current_user) self.set_login_cookie(current_user)
without_prefix = self.request.uri[len(self.hub.server.base_url):] without_prefix = self.request.uri[len(self.hub.server.base_url):]
target = url_path_join(self.base_url, without_prefix) target = url_path_join(self.base_url, without_prefix)
if self.use_subdomains:
target = current_user.host + target
self.redirect(target) self.redirect(target)
else: else:
# not logged in to the right user, # not logged in to the right user,

View File

@@ -25,7 +25,7 @@ class RootHandler(BaseHandler):
user = self.get_current_user() user = self.get_current_user()
if user: if user:
if user.running: if user.running:
url = user.server.base_url url = user.url
self.log.debug("User is running: %s", url) self.log.debug("User is running: %s", url)
else: else:
url = url_path_join(self.hub.server.base_url, 'home') 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""" """GET renders form for spawning with user-specified options"""
user = self.get_current_user() user = self.get_current_user()
if user.running: if user.running:
url = user.server.base_url url = user.url
self.log.debug("User is running: %s", url) self.log.debug("User is running: %s", url)
self.redirect(url) self.redirect(url)
return return
@@ -84,7 +84,7 @@ class SpawnHandler(BaseHandler):
"""POST spawns with user-specified options""" """POST spawns with user-specified options"""
user = self.get_current_user() user = self.get_current_user()
if user.running: if user.running:
url = user.server.base_url url = user.url
self.log.warning("User is already running: %s", url) self.log.warning("User is already running: %s", url)
self.redirect(url) self.redirect(url)
return return
@@ -101,7 +101,7 @@ class SpawnHandler(BaseHandler):
self.finish(self._render_form(str(e))) self.finish(self._render_form(str(e)))
return return
self.set_login_cookie(user) self.set_login_cookie(user)
url = user.server.base_url url = user.url
self.redirect(url) self.redirect(url)
class AdminHandler(BaseHandler): class AdminHandler(BaseHandler):

View File

@@ -176,10 +176,10 @@ class Proxy(Base):
def add_user(self, user, client=None): def add_user(self, user, client=None):
"""Add a user's server to the proxy table.""" """Add a user's server to the proxy table."""
self.log.info("Adding user %s to proxy %s => %s", 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', method='POST',
body=dict( body=dict(
target=user.server.host, target=user.server.host,
@@ -192,26 +192,11 @@ class Proxy(Base):
def delete_user(self, user, client=None): def delete_user(self, user, client=None):
"""Remove a user's server to the proxy table.""" """Remove a user's server to the proxy table."""
self.log.info("Removing user %s from proxy", user.name) 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', method='DELETE',
client=client, 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 @gen.coroutine
def get_routes(self, client=None): def get_routes(self, client=None):
"""Fetch the proxy's routes""" """Fetch the proxy's routes"""
@@ -219,15 +204,32 @@ class Proxy(Base):
return json.loads(resp.body.decode('utf8', 'replace')) return json.loads(resp.body.decode('utf8', 'replace'))
@gen.coroutine @gen.coroutine
def check_routes(self, routes=None): def add_all_users(self, user_dict):
"""Check that all users are properly""" """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: if not routes:
routes = yield self.get_routes() routes = yield self.get_routes()
have_routes = { r['user'] for r in routes.values() if 'user' in r } have_routes = { r['user'] for r in routes.values() if 'user' in r }
futures = [] futures = []
db = inspect(self).session 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: if user.server is None:
# This should never be True, but seems to be on rare occasion. # This should never be True, but seems to be on rare occasion.
# catch filter bug, either in sqlalchemy or my understanding of its behavior # 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) id = Column(Integer, primary_key=True)
_server_id = Column(Integer, ForeignKey('servers.id')) _server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id) server = relationship(Server, primaryjoin=_server_id == Server.id)
host = ''
@property @property
def api_url(self): def api_url(self):

View File

@@ -199,6 +199,7 @@ class Spawner(LoggingConfigurable):
'--port=%i' % self.user.server.port, '--port=%i' % self.user.server.port,
'--cookie-name=%s' % self.user.server.cookie_name, '--cookie-name=%s' % self.user.server.cookie_name,
'--base-url=%s' % self.user.server.base_url, '--base-url=%s' % self.user.server.base_url,
'--hub-host=%s' % self.hub.host,
'--hub-prefix=%s' % self.hub.server.base_url, '--hub-prefix=%s' % self.hub.server.base_url,
'--hub-api-url=%s' % self.hub.api_url, '--hub-api-url=%s' % self.hub.api_url,
] ]

View File

@@ -1,7 +1,7 @@
"""mock utilities for testing""" """mock utilities for testing"""
import os
import sys import sys
from datetime import timedelta
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import threading import threading
@@ -13,10 +13,11 @@ from tornado import gen
from tornado.concurrent import Future from tornado.concurrent import Future
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from ..spawner import LocalProcessSpawner
from ..app import JupyterHub from ..app import JupyterHub
from ..auth import PAMAuthenticator from ..auth import PAMAuthenticator
from .. import orm from .. import orm
from ..spawner import LocalProcessSpawner
from ..utils import url_path_join
from pamela import PAMError from pamela import PAMError
@@ -110,6 +111,12 @@ class MockHub(JupyterHub):
db_file = None db_file = None
confirm_no_ssl = True 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): def _ip_default(self):
return '127.0.0.1' return '127.0.0.1'
@@ -161,7 +168,8 @@ class MockHub(JupyterHub):
self.db_file.close() self.db_file.close()
def login_user(self, name): 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={ data={
'username': name, 'username': name,
'password': name, 'password': name,
@@ -171,3 +179,21 @@ class MockHub(JupyterHub):
assert r.cookies assert r.cookies
return 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

View File

@@ -2,7 +2,6 @@
import json import json
import time import time
from datetime import timedelta
from queue import Queue from queue import Queue
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -14,6 +13,7 @@ from .. import orm
from ..user import User from ..user import User
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
from . import mocking from . import mocking
from .mocking import public_url, user_url
def check_db_locks(func): def check_db_locks(func):
@@ -105,7 +105,7 @@ def test_auth_api(app):
def test_referer_check(app, io_loop): 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 host = urlparse(url).netloc
user = find_user(app.db, 'admin') user = find_user(app.db, 'admin')
if user is None: if user is None:
@@ -352,15 +352,19 @@ def test_spawn(app, io_loop):
assert status is None assert status is None
assert user.server.base_url == '/user/%s' % name 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.status_code == 200
assert r.text == user.server.base_url 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 assert r.status_code == 200
argv = r.json() argv = r.json()
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]: for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
assert expected in argv 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') r = api_request(app, 'users', name, 'server', method='delete')
assert r.status_code == 204 assert r.status_code == 204

View File

@@ -8,12 +8,11 @@ from ..utils import url_path_join as ujoin
from .. import orm from .. import orm
import mock import mock
from .mocking import FormSpawner from .mocking import FormSpawner, public_url, public_host, user_url
from .test_api import api_request from .test_api import api_request
def get_page(path, app, **kw): 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) print(base_url)
return requests.get(ujoin(base_url, path), **kw) 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) routes = io_loop.run_sync(app.proxy.get_routes)
print(routes) print(routes)
print(app.hub.server) 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() 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): def test_root_auth(app):
cookies = app.login_user('river') 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() 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): def test_home_no_auth(app):
r = get_page('home', app, allow_redirects=False) r = get_page('home', app, allow_redirects=False)
@@ -100,7 +101,7 @@ def test_spawn_page(app):
def test_spawn_form(app, io_loop): def test_spawn_form(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): 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') cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones') orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u] 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): def test_spawn_form_with_file(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): 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') cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones') orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u] u = app.users[orm_u]
@@ -149,7 +150,7 @@ def test_spawn_form_with_file(app, io_loop):
def test_static_files(app): 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) print(base_url)
r = requests.get(ujoin(base_url, 'logo')) r = requests.get(ujoin(base_url, 'logo'))
r.raise_for_status() r.raise_for_status()

View File

@@ -4,6 +4,7 @@ import json
import os import os
from queue import Queue from queue import Queue
from subprocess import Popen from subprocess import Popen
from urllib.parse import urlparse
from .. import orm from .. import orm
from .mocking import MockHub from .mocking import MockHub
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port), '--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_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) proxy = Popen(cmd, env=env)
def _cleanup_proxy(): def _cleanup_proxy():
if proxy.poll() is None: if proxy.poll() is None:
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
r.raise_for_status() r.raise_for_status()
routes = io_loop.run_sync(app.proxy.get_routes) 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 # teardown the proxy and start a new one in the same place
proxy.terminate() proxy.terminate()
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct # check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes) 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 # teardown the proxy again, and start a new one with different auth and port
proxy.terminate() proxy.terminate()
@@ -90,7 +97,8 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port), '--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_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) proxy = Popen(cmd, env=env)
wait_for_proxy() wait_for_proxy()
@@ -113,7 +121,7 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct # check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes) 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): def test_check_routes(app, io_loop):
proxy = app.proxy proxy = app.proxy
@@ -123,13 +131,14 @@ def test_check_routes(app, io_loop):
r.raise_for_status() r.raise_for_status()
zoe = orm.User.find(app.db, 'zoe') zoe = orm.User.find(app.db, 'zoe')
assert zoe is not None assert zoe is not None
zoe = app.users[zoe]
before = sorted(io_loop.run_sync(app.proxy.get_routes)) 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(app.proxy.check_routes) io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
io_loop.run_sync(lambda : proxy.delete_user(zoe)) io_loop.run_sync(lambda : proxy.delete_user(zoe))
during = sorted(io_loop.run_sync(app.proxy.get_routes)) 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(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)) after = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' in after assert zoe.proxy_path in after
assert before == after assert before == after

View File

@@ -2,7 +2,7 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import quote from urllib.parse import quote, urlparse
from tornado import gen from tornado import gen
from tornado.log import app_log from tornado.log import app_log
@@ -38,6 +38,12 @@ class UserDict(dict):
def __getitem__(self, key): def __getitem__(self, key):
if isinstance(key, User): if isinstance(key, User):
key = key.id 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): if isinstance(key, orm.User):
# users[orm_user] returns User(orm_user) # users[orm_user] returns User(orm_user)
orm_user = key orm_user = key
@@ -148,6 +154,41 @@ class User(HasTraits):
"""My name, escaped for use in URLs, cookies, etc.""" """My name, escaped for use in URLs, cookies, etc."""
return quote(self.name, safe='@') 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 @gen.coroutine
def spawn(self, options=None): def spawn(self, options=None):
"""Start the user's spawner""" """Start the user's spawner"""

View File

@@ -104,7 +104,9 @@ class JupyterHubLoginHandler(LoginHandler):
class JupyterHubLogoutHandler(LogoutHandler): class JupyterHubLogoutHandler(LogoutHandler):
def get(self): 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 # register new hub related command-line aliases
@@ -113,6 +115,7 @@ aliases.update({
'user' : 'SingleUserNotebookApp.user', 'user' : 'SingleUserNotebookApp.user',
'cookie-name': 'SingleUserNotebookApp.cookie_name', 'cookie-name': 'SingleUserNotebookApp.cookie_name',
'hub-prefix': 'SingleUserNotebookApp.hub_prefix', 'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
'hub-host': 'SingleUserNotebookApp.hub_host',
'hub-api-url': 'SingleUserNotebookApp.hub_api_url', 'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
'base-url': 'SingleUserNotebookApp.base_url', 'base-url': 'SingleUserNotebookApp.base_url',
}) })
@@ -141,6 +144,7 @@ class SingleUserNotebookApp(NotebookApp):
self.log.name = new self.log.name = new
cookie_name = Unicode(config=True) cookie_name = Unicode(config=True)
hub_prefix = Unicode(config=True) hub_prefix = Unicode(config=True)
hub_host = Unicode(config=True)
hub_api_url = Unicode(config=True) hub_api_url = Unicode(config=True)
aliases = aliases aliases = aliases
open_browser = False open_browser = False
@@ -194,22 +198,22 @@ class SingleUserNotebookApp(NotebookApp):
s['user'] = self.user s['user'] = self.user
s['hub_api_key'] = env.pop('JPY_API_TOKEN') s['hub_api_key'] = env.pop('JPY_API_TOKEN')
s['hub_prefix'] = self.hub_prefix s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
s['cookie_name'] = self.cookie_name 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['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() super(SingleUserNotebookApp, self).init_webapp()
self.patch_templates() self.patch_templates()
def patch_templates(self): def patch_templates(self):
"""Patch page templates to add Hub-related buttons""" """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 = self.web_app.settings['jinja2_env']
env.globals['hub_control_panel_url'] = \ 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 # patch jinja env loading to modify page template
def get_page(name): def get_page(name):