mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 04:53:01 +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 = {
|
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(),
|
||||||
}
|
}
|
||||||
|
@@ -230,8 +230,20 @@ 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_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,
|
port = Integer(8000, config=True,
|
||||||
help="The public facing port of the proxy"
|
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."
|
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,
|
proxy_cmd = Command('configurable-http-proxy', config=True,
|
||||||
help="""The command to start the http proxy.
|
help="""The command to start the http proxy.
|
||||||
|
|
||||||
@@ -769,6 +793,9 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
self.db.add(self.proxy)
|
self.db.add(self.proxy)
|
||||||
self.db.commit()
|
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.auth_token = self.proxy_auth_token # not persisted
|
||||||
self.proxy.log = self.log
|
self.proxy.log = self.log
|
||||||
self.proxy.public_server.ip = self.ip
|
self.proxy.public_server.ip = self.ip
|
||||||
@@ -809,6 +836,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:
|
||||||
@@ -893,6 +922,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 = subdomain_host.rsplit(':', 1)[0]
|
||||||
settings = dict(
|
settings = dict(
|
||||||
log_function=log_request,
|
log_function=log_request,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
@@ -915,6 +946,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)
|
||||||
|
@@ -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,18 +207,30 @@ 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:
|
||||||
|
# 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:
|
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_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
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
# 'secure' kwarg is passed to set_secure_cookie
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
if self.request.protocol == 'https':
|
if self.request.protocol == 'https':
|
||||||
kwargs = {'secure':True}
|
kwargs = {'secure': True}
|
||||||
else:
|
else:
|
||||||
kwargs = {}
|
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(
|
self.set_secure_cookie(
|
||||||
user.server.cookie_name,
|
user.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
@@ -223,9 +243,15 @@ class BaseHandler(RequestHandler):
|
|||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
# 'secure' kwarg is passed to set_secure_cookie
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
if self.request.protocol == 'https':
|
if self.request.protocol == 'https':
|
||||||
kwargs = {'secure':True}
|
kwargs = {'secure': True}
|
||||||
else:
|
else:
|
||||||
kwargs = {}
|
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.set_secure_cookie(
|
||||||
self.hub.server.cookie_name,
|
self.hub.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
@@ -465,6 +491,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,
|
||||||
|
@@ -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):
|
||||||
|
@@ -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,7 +192,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
@@ -148,6 +148,39 @@ 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
|
||||||
|
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
|
@gen.coroutine
|
||||||
def spawn(self, options=None):
|
def spawn(self, options=None):
|
||||||
"""Start the user's spawner"""
|
"""Start the user's spawner"""
|
||||||
|
Reference in New Issue
Block a user