[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:
Min RK
2016-02-22 16:45:50 +01:00
parent 6e7fc0574e
commit b54bfad8c2
6 changed files with 108 additions and 13 deletions

View File

@@ -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(),
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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):

View File

@@ -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,
)

View File

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