mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-12 20:43:02 +00:00
[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.
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
@@ -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 <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,
|
||||
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)
|
||||
|
@@ -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,9 +207,14 @@ 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"""
|
||||
@@ -211,6 +224,13 @@ class BaseHandler(RequestHandler):
|
||||
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,
|
||||
@@ -226,6 +246,12 @@ class BaseHandler(RequestHandler):
|
||||
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,
|
||||
|
@@ -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):
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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"""
|
||||
|
Reference in New Issue
Block a user