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"""