mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 02:24:08 +00:00
1111 lines
42 KiB
Python
1111 lines
42 KiB
Python
"""HTTP Handlers for the hub server"""
|
|
|
|
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
|
|
import copy
|
|
from datetime import datetime, timedelta
|
|
from http.client import responses
|
|
import math
|
|
import random
|
|
import re
|
|
import time
|
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
|
import uuid
|
|
|
|
from jinja2 import TemplateNotFound
|
|
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from tornado.log import app_log
|
|
from tornado.httputil import url_concat, HTTPHeaders
|
|
from tornado.ioloop import IOLoop
|
|
from tornado.web import RequestHandler
|
|
from tornado import gen, web
|
|
|
|
from .. import __version__
|
|
from .. import orm
|
|
from ..objects import Server
|
|
from ..spawner import LocalProcessSpawner
|
|
from ..utils import maybe_future, url_path_join
|
|
from ..metrics import (
|
|
SERVER_SPAWN_DURATION_SECONDS, ServerSpawnStatus,
|
|
PROXY_ADD_DURATION_SECONDS, ProxyAddStatus,
|
|
)
|
|
|
|
# pattern for the authentication token header
|
|
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
|
|
|
|
# mapping of reason: reason_message
|
|
reasons = {
|
|
'timeout': "Failed to reach your server."
|
|
" Please try again later."
|
|
" Contact admin if the issue persists.",
|
|
'error': "Failed to start your server. Please contact admin.",
|
|
}
|
|
|
|
# constant, not configurable
|
|
SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
|
|
|
class BaseHandler(RequestHandler):
|
|
"""Base Handler class with access to common methods and properties."""
|
|
|
|
@property
|
|
def log(self):
|
|
"""I can't seem to avoid typing self.log"""
|
|
return self.settings.get('log', app_log)
|
|
|
|
@property
|
|
def config(self):
|
|
return self.settings.get('config', None)
|
|
|
|
@property
|
|
def base_url(self):
|
|
return self.settings.get('base_url', '/')
|
|
|
|
@property
|
|
def default_url(self):
|
|
return self.settings.get('default_url', '')
|
|
|
|
@property
|
|
def version_hash(self):
|
|
return self.settings.get('version_hash', '')
|
|
|
|
@property
|
|
def subdomain_host(self):
|
|
return self.settings.get('subdomain_host', '')
|
|
|
|
@property
|
|
def allow_named_servers(self):
|
|
return self.settings.get('allow_named_servers', False)
|
|
|
|
@property
|
|
def domain(self):
|
|
return self.settings['domain']
|
|
|
|
@property
|
|
def db(self):
|
|
return self.settings['db']
|
|
|
|
@property
|
|
def users(self):
|
|
return self.settings.setdefault('users', {})
|
|
|
|
@property
|
|
def services(self):
|
|
return self.settings.setdefault('services', {})
|
|
|
|
@property
|
|
def hub(self):
|
|
return self.settings['hub']
|
|
|
|
@property
|
|
def proxy(self):
|
|
return self.settings['proxy']
|
|
|
|
@property
|
|
def statsd(self):
|
|
return self.settings['statsd']
|
|
|
|
@property
|
|
def authenticator(self):
|
|
return self.settings.get('authenticator', None)
|
|
|
|
@property
|
|
def oauth_provider(self):
|
|
return self.settings['oauth_provider']
|
|
|
|
def finish(self, *args, **kwargs):
|
|
"""Roll back any uncommitted transactions from the handler."""
|
|
if self.db.dirty:
|
|
self.log.warning("Rolling back dirty objects %s", self.db.dirty)
|
|
self.db.rollback()
|
|
super().finish(*args, **kwargs)
|
|
|
|
#---------------------------------------------------------------
|
|
# Security policies
|
|
#---------------------------------------------------------------
|
|
|
|
@property
|
|
def csp_report_uri(self):
|
|
return self.settings.get('csp_report_uri',
|
|
url_path_join(self.hub.base_url, 'security/csp-report')
|
|
)
|
|
|
|
@property
|
|
def content_security_policy(self):
|
|
"""The default Content-Security-Policy header
|
|
|
|
Can be overridden by defining Content-Security-Policy in settings['headers']
|
|
"""
|
|
return '; '.join([
|
|
"frame-ancestors 'self'",
|
|
"report-uri " + self.csp_report_uri,
|
|
])
|
|
|
|
def get_content_type(self):
|
|
return 'text/html'
|
|
|
|
def set_default_headers(self):
|
|
"""
|
|
Set any headers passed as tornado_settings['headers'].
|
|
|
|
By default sets Content-Security-Policy of frame-ancestors 'self'.
|
|
Also responsible for setting content-type header
|
|
"""
|
|
# wrap in HTTPHeaders for case-insensitivity
|
|
headers = HTTPHeaders(self.settings.get('headers', {}))
|
|
headers.setdefault("X-JupyterHub-Version", __version__)
|
|
|
|
for header_name, header_content in headers.items():
|
|
self.set_header(header_name, header_content)
|
|
|
|
if 'Access-Control-Allow-Headers' not in headers:
|
|
self.set_header('Access-Control-Allow-Headers', 'accept, content-type, authorization')
|
|
if 'Content-Security-Policy' not in headers:
|
|
self.set_header('Content-Security-Policy', self.content_security_policy)
|
|
self.set_header('Content-Type', self.get_content_type())
|
|
|
|
#---------------------------------------------------------------
|
|
# Login and cookie-related
|
|
#---------------------------------------------------------------
|
|
|
|
@property
|
|
def admin_users(self):
|
|
return self.settings.setdefault('admin_users', set())
|
|
|
|
@property
|
|
def cookie_max_age_days(self):
|
|
return self.settings.get('cookie_max_age_days', None)
|
|
|
|
@property
|
|
def redirect_to_server(self):
|
|
return self.settings.get('redirect_to_server', True)
|
|
|
|
def get_auth_token(self):
|
|
"""Get the authorization token from Authorization header"""
|
|
auth_header = self.request.headers.get('Authorization', '')
|
|
match = auth_header_pat.match(auth_header)
|
|
if not match:
|
|
return None
|
|
return match.group(1)
|
|
|
|
def get_current_user_oauth_token(self):
|
|
"""Get the current user identified by OAuth access token
|
|
|
|
Separate from API token because OAuth access tokens
|
|
can only be used for identifying users,
|
|
not using the API.
|
|
"""
|
|
token = self.get_auth_token()
|
|
if token is None:
|
|
return None
|
|
orm_token = orm.OAuthAccessToken.find(self.db, token)
|
|
if orm_token is None:
|
|
return None
|
|
orm_token.last_activity = \
|
|
orm_token.user.last_activity = datetime.utcnow()
|
|
self.db.commit()
|
|
return self._user_from_orm(orm_token.user)
|
|
|
|
def get_current_user_token(self):
|
|
"""get_current_user from Authorization header token"""
|
|
token = self.get_auth_token()
|
|
if token is None:
|
|
return None
|
|
orm_token = orm.APIToken.find(self.db, token)
|
|
if orm_token is None:
|
|
return None
|
|
else:
|
|
# record token activity
|
|
now = datetime.utcnow()
|
|
orm_token.last_activity = now
|
|
if orm_token.user:
|
|
orm_token.user.last_activity = now
|
|
|
|
self.db.commit()
|
|
return orm_token.service or self._user_from_orm(orm_token.user)
|
|
|
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
|
"""Get the User for a given cookie, if there is one"""
|
|
cookie_id = self.get_secure_cookie(
|
|
cookie_name,
|
|
cookie_value,
|
|
max_age_days=self.cookie_max_age_days,
|
|
)
|
|
def clear():
|
|
self.clear_cookie(cookie_name, path=self.hub.base_url)
|
|
|
|
if cookie_id is None:
|
|
if self.get_cookie(cookie_name):
|
|
self.log.warning("Invalid or expired cookie token")
|
|
clear()
|
|
return
|
|
cookie_id = cookie_id.decode('utf8', 'replace')
|
|
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
|
user = self._user_from_orm(u)
|
|
if user is None:
|
|
self.log.warning("Invalid cookie token")
|
|
# have cookie, but it's not valid. Clear it and start over.
|
|
clear()
|
|
return
|
|
# update user activity
|
|
user.last_activity = datetime.utcnow()
|
|
self.db.commit()
|
|
return user
|
|
|
|
def _user_from_orm(self, orm_user):
|
|
"""return User wrapper from orm.User object"""
|
|
if orm_user is None:
|
|
return
|
|
return self.users[orm_user]
|
|
|
|
def get_current_user_cookie(self):
|
|
"""get_current_user from a cookie token"""
|
|
return self._user_for_cookie(self.hub.cookie_name)
|
|
|
|
def get_current_user(self):
|
|
"""get current username"""
|
|
if not hasattr(self, '_jupyterhub_user'):
|
|
try:
|
|
user = self.get_current_user_token()
|
|
if user is None:
|
|
user = self.get_current_user_cookie()
|
|
self._jupyterhub_user = user
|
|
except Exception:
|
|
# don't let errors here raise more than once
|
|
self._jupyterhub_user = None
|
|
raise
|
|
return self._jupyterhub_user
|
|
|
|
def find_user(self, name):
|
|
"""Get a user by name
|
|
|
|
return None if no such user
|
|
"""
|
|
orm_user = orm.User.find(db=self.db, name=name)
|
|
return self._user_from_orm(orm_user)
|
|
|
|
def user_from_username(self, username):
|
|
"""Get User for username, creating if it doesn't exist"""
|
|
user = self.find_user(username)
|
|
if user is None:
|
|
# not found, create and register user
|
|
u = orm.User(name=username)
|
|
self.db.add(u)
|
|
self.db.commit()
|
|
user = self._user_from_orm(u)
|
|
return user
|
|
|
|
def clear_login_cookie(self, name=None):
|
|
kwargs = {}
|
|
if self.subdomain_host:
|
|
kwargs['domain'] = self.domain
|
|
user = self.get_current_user_cookie()
|
|
session_id = self.get_session_cookie()
|
|
if session_id:
|
|
# clear session id
|
|
self.clear_cookie(SESSION_COOKIE_NAME, **kwargs)
|
|
|
|
if user:
|
|
# user is logged in, clear any tokens associated with the current session
|
|
# don't clear session tokens if not logged in,
|
|
# because that could be a malicious logout request!
|
|
count = 0
|
|
for access_token in (
|
|
self.db.query(orm.OAuthAccessToken)
|
|
.filter(orm.OAuthAccessToken.user_id==user.id)
|
|
.filter(orm.OAuthAccessToken.session_id==session_id)
|
|
):
|
|
self.db.delete(access_token)
|
|
count += 1
|
|
if count:
|
|
self.log.debug("Deleted %s access tokens for %s", count, user.name)
|
|
self.db.commit()
|
|
|
|
|
|
# clear hub cookie
|
|
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
|
# clear services cookie
|
|
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'), **kwargs)
|
|
|
|
def _set_cookie(self, key, value, encrypted=True, **overrides):
|
|
"""Setting any cookie should go through here
|
|
|
|
if encrypted use tornado's set_secure_cookie,
|
|
otherwise set plaintext cookies.
|
|
"""
|
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
|
# 'secure' kwarg is passed to set_secure_cookie
|
|
kwargs = {
|
|
'httponly': True,
|
|
}
|
|
if self.request.protocol == 'https':
|
|
kwargs['secure'] = True
|
|
if self.subdomain_host:
|
|
kwargs['domain'] = self.domain
|
|
|
|
kwargs.update(self.settings.get('cookie_options', {}))
|
|
kwargs.update(overrides)
|
|
|
|
if encrypted:
|
|
set_cookie = self.set_secure_cookie
|
|
else:
|
|
set_cookie = self.set_cookie
|
|
|
|
self.log.debug("Setting cookie %s: %s", key, kwargs)
|
|
set_cookie(key, value, **kwargs)
|
|
|
|
|
|
def _set_user_cookie(self, user, server):
|
|
self.log.debug("Setting cookie for %s: %s", user.name, server.cookie_name)
|
|
self._set_cookie(
|
|
server.cookie_name,
|
|
user.cookie_id,
|
|
encrypted=True,
|
|
path=server.base_url,
|
|
)
|
|
|
|
def get_session_cookie(self):
|
|
"""Get the session id from a cookie
|
|
|
|
Returns None if no session id is stored
|
|
"""
|
|
return self.get_cookie(SESSION_COOKIE_NAME, None)
|
|
|
|
def set_session_cookie(self):
|
|
"""Set a new session id cookie
|
|
|
|
new session id is returned
|
|
|
|
Session id cookie is *not* encrypted,
|
|
so other services on this domain can read it.
|
|
"""
|
|
session_id = uuid.uuid4().hex
|
|
self._set_cookie(SESSION_COOKIE_NAME, session_id, encrypted=False)
|
|
return session_id
|
|
|
|
def set_service_cookie(self, user):
|
|
"""set the login cookie for services"""
|
|
self._set_user_cookie(user, orm.Server(
|
|
cookie_name='jupyterhub-services',
|
|
base_url=url_path_join(self.base_url, 'services')
|
|
))
|
|
|
|
def set_hub_cookie(self, user):
|
|
"""set the login cookie for the Hub"""
|
|
self._set_user_cookie(user, self.hub)
|
|
|
|
def set_login_cookie(self, user):
|
|
"""Set login cookies for the Hub and single-user server."""
|
|
if self.subdomain_host and not self.request.host.startswith(self.domain):
|
|
self.log.warning(
|
|
"Possibly setting cookie on wrong domain: %s != %s",
|
|
self.request.host, self.domain)
|
|
|
|
# set single cookie for services
|
|
if self.db.query(orm.Service).filter(orm.Service.server != None).first():
|
|
self.set_service_cookie(user)
|
|
|
|
if not self.get_session_cookie():
|
|
self.set_session_cookie()
|
|
|
|
# create and set a new cookie token for the hub
|
|
if not self.get_current_user_cookie():
|
|
self.set_hub_cookie(user)
|
|
|
|
def authenticate(self, data):
|
|
return maybe_future(self.authenticator.get_authenticated_user(self, data))
|
|
|
|
def get_next_url(self, user=None):
|
|
"""Get the next_url for login redirect
|
|
|
|
Default URL after login:
|
|
|
|
- if redirect_to_server (default): send to user's own server
|
|
- else: /hub/home
|
|
"""
|
|
next_url = self.get_argument('next', default='')
|
|
if (next_url + '/').startswith(
|
|
(
|
|
'%s://%s/' % (self.request.protocol, self.request.host),
|
|
'//%s/' % self.request.host,
|
|
)
|
|
):
|
|
# treat absolute URLs for our host as absolute paths:
|
|
parsed = urlparse(next_url)
|
|
next_url = parsed.path
|
|
if parsed.query:
|
|
next_url = next_url + '?' + parsed.query
|
|
if parsed.hash:
|
|
next_url = next_url + '#' + parsed.hash
|
|
if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
|
|
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
|
next_url = ''
|
|
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
|
|
# add /hub/ prefix, to ensure we redirect to the right user's server.
|
|
# The next request will be handled by SpawnHandler,
|
|
# ultimately redirecting to the logged-in user's server.
|
|
without_prefix = next_url[len(self.base_url):]
|
|
next_url = url_path_join(self.hub.base_url, without_prefix)
|
|
self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
|
|
self.request.uri, next_url,
|
|
)
|
|
|
|
if not next_url:
|
|
# custom default URL
|
|
next_url = self.default_url
|
|
|
|
if not next_url:
|
|
# default URL after login
|
|
# if self.redirect_to_server, default login URL initiates spawn,
|
|
# otherwise send to Hub home page (control panel)
|
|
if user and self.redirect_to_server:
|
|
next_url = user.url
|
|
else:
|
|
next_url = url_path_join(self.hub.base_url, 'home')
|
|
return next_url
|
|
|
|
async def login_user(self, data=None):
|
|
"""Login a user"""
|
|
auth_timer = self.statsd.timer('login.authenticate').start()
|
|
authenticated = await self.authenticate(data)
|
|
auth_timer.stop(send=False)
|
|
|
|
if authenticated:
|
|
username = authenticated['name']
|
|
auth_state = authenticated.get('auth_state')
|
|
admin = authenticated.get('admin')
|
|
new_user = username not in self.users
|
|
user = self.user_from_username(username)
|
|
if new_user:
|
|
await maybe_future(self.authenticator.add_user(user))
|
|
# Only set `admin` if the authenticator returned an explicit value.
|
|
if admin is not None and admin != user.admin:
|
|
user.admin = admin
|
|
self.db.commit()
|
|
# always set auth_state and commit,
|
|
# because there could be key-rotation or clearing of previous values
|
|
# going on.
|
|
if not self.authenticator.enable_auth_state:
|
|
# auth_state is not enabled. Force None.
|
|
auth_state = None
|
|
await user.save_auth_state(auth_state)
|
|
self.db.commit()
|
|
self.set_login_cookie(user)
|
|
self.statsd.incr('login.success')
|
|
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
|
self.log.info("User logged in: %s", username)
|
|
return user
|
|
else:
|
|
self.statsd.incr('login.failure')
|
|
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
|
self.log.warning("Failed login for %s", (data or {}).get('username', 'unknown user'))
|
|
|
|
|
|
#---------------------------------------------------------------
|
|
# spawning-related
|
|
#---------------------------------------------------------------
|
|
|
|
@property
|
|
def slow_spawn_timeout(self):
|
|
return self.settings.get('slow_spawn_timeout', 10)
|
|
|
|
@property
|
|
def slow_stop_timeout(self):
|
|
return self.settings.get('slow_stop_timeout', 10)
|
|
|
|
@property
|
|
def spawner_class(self):
|
|
return self.settings.get('spawner_class', LocalProcessSpawner)
|
|
|
|
@property
|
|
def concurrent_spawn_limit(self):
|
|
return self.settings.get('concurrent_spawn_limit', 0)
|
|
|
|
@property
|
|
def active_server_limit(self):
|
|
return self.settings.get('active_server_limit', 0)
|
|
|
|
async def spawn_single_user(self, user, server_name='', options=None):
|
|
# in case of error, include 'try again from /hub/home' message
|
|
spawn_start_time = time.perf_counter()
|
|
self.extra_error_html = self.spawn_home_error
|
|
|
|
user_server_name = user.name
|
|
|
|
if server_name:
|
|
user_server_name = '%s:%s' % (user.name, server_name)
|
|
|
|
if server_name in user.spawners and user.spawners[server_name].pending:
|
|
pending = user.spawners[server_name].pending
|
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
|
status=ServerSpawnStatus.already_pending
|
|
).observe(time.perf_counter() - spawn_start_time)
|
|
raise RuntimeError("%s pending %s" % (user_server_name, pending))
|
|
|
|
# count active servers and pending spawns
|
|
# we could do careful bookkeeping to avoid
|
|
# but for 10k users this takes ~5ms
|
|
# and saves us from bookkeeping errors
|
|
active_counts = self.users.count_active_users()
|
|
spawn_pending_count = active_counts['spawn_pending'] + active_counts['proxy_pending']
|
|
active_count = active_counts['active']
|
|
|
|
concurrent_spawn_limit = self.concurrent_spawn_limit
|
|
active_server_limit = self.active_server_limit
|
|
|
|
if concurrent_spawn_limit and spawn_pending_count >= concurrent_spawn_limit:
|
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
|
status=ServerSpawnStatus.throttled
|
|
).observe(time.perf_counter() - spawn_start_time)
|
|
# Suggest number of seconds client should wait before retrying
|
|
# This helps prevent thundering herd problems, where users simply
|
|
# immediately retry when we are overloaded.
|
|
retry_range = self.settings['spawn_throttle_retry_range']
|
|
retry_time = int(random.uniform(*retry_range))
|
|
|
|
# round suggestion to nicer human value (nearest 10 seconds or minute)
|
|
if retry_time <= 90:
|
|
# round human seconds up to nearest 10
|
|
human_retry_time = "%i0 seconds" % math.ceil(retry_time / 10.)
|
|
else:
|
|
# round number of minutes
|
|
human_retry_time = "%i minutes" % math.round(retry_time / 60.)
|
|
|
|
self.log.warning(
|
|
'%s pending spawns, throttling. Suggested retry in %s seconds.',
|
|
spawn_pending_count, retry_time,
|
|
)
|
|
err = web.HTTPError(
|
|
429,
|
|
"Too many users trying to log in right now. Try again in {}.".format(human_retry_time)
|
|
)
|
|
# can't call set_header directly here because it gets ignored
|
|
# when errors are raised
|
|
# we handle err.headers ourselves in Handler.write_error
|
|
err.headers = {'Retry-After': retry_time}
|
|
raise err
|
|
|
|
if active_server_limit and active_count >= active_server_limit:
|
|
self.log.info(
|
|
'%s servers active, no space available',
|
|
active_count,
|
|
)
|
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
|
status=ServerSpawnStatus.too_many_users
|
|
).observe(time.perf_counter() - spawn_start_time)
|
|
raise web.HTTPError(429, "Active user limit exceeded. Try again in a few minutes.")
|
|
|
|
tic = IOLoop.current().time()
|
|
|
|
self.log.debug("Initiating spawn for %s", user_server_name)
|
|
|
|
spawn_future = user.spawn(server_name, options)
|
|
|
|
self.log.debug("%i%s concurrent spawns",
|
|
spawn_pending_count,
|
|
'/%i' % concurrent_spawn_limit if concurrent_spawn_limit else '')
|
|
self.log.debug("%i%s active servers",
|
|
active_count,
|
|
'/%i' % active_server_limit if active_server_limit else '')
|
|
|
|
spawner = user.spawners[server_name]
|
|
# set spawn_pending now, so there's no gap where _spawn_pending is False
|
|
# while we are waiting for _proxy_pending to be set
|
|
spawner._spawn_pending = True
|
|
|
|
async def finish_user_spawn():
|
|
"""Finish the user spawn by registering listeners and notifying the proxy.
|
|
|
|
If the spawner is slow to start, this is passed as an async callback,
|
|
otherwise it is called immediately.
|
|
"""
|
|
# wait for spawn Future
|
|
await spawn_future
|
|
toc = IOLoop.current().time()
|
|
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
|
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
|
status=ServerSpawnStatus.success
|
|
).observe(time.perf_counter() - spawn_start_time)
|
|
proxy_add_start_time = time.perf_counter()
|
|
spawner._proxy_pending = True
|
|
try:
|
|
await self.proxy.add_user(user, server_name)
|
|
|
|
PROXY_ADD_DURATION_SECONDS.labels(
|
|
status='success'
|
|
).observe(
|
|
time.perf_counter() - proxy_add_start_time
|
|
)
|
|
except Exception:
|
|
self.log.exception("Failed to add %s to proxy!", user_server_name)
|
|
self.log.error("Stopping %s to avoid inconsistent state", user_server_name)
|
|
await user.stop()
|
|
PROXY_ADD_DURATION_SECONDS.labels(
|
|
status='failure'
|
|
).observe(
|
|
time.perf_counter() - proxy_add_start_time
|
|
)
|
|
else:
|
|
spawner.add_poll_callback(self.user_stopped, user, server_name)
|
|
finally:
|
|
spawner._proxy_pending = False
|
|
|
|
# hook up spawner._spawn_future so that other requests can await
|
|
# this result
|
|
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
|
def _clear_spawn_future(f):
|
|
# clear spawner._spawn_future when it's done
|
|
# keep an exception around, though, to prevent repeated implicit spawns
|
|
# if spawn is failing
|
|
if f.exception() is None:
|
|
spawner._spawn_future = None
|
|
# Now we're all done. clear _spawn_pending flag
|
|
spawner._spawn_pending = False
|
|
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
|
|
|
try:
|
|
await gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
|
|
except gen.TimeoutError:
|
|
# waiting_for_response indicates server process has started,
|
|
# but is yet to become responsive.
|
|
if spawner._spawn_pending and not spawner._waiting_for_response:
|
|
# still in Spawner.start, which is taking a long time
|
|
# we shouldn't poll while spawn is incomplete.
|
|
self.log.warning("User %s is slow to start (timeout=%s)",
|
|
user_server_name, self.slow_spawn_timeout)
|
|
return
|
|
|
|
# start has finished, but the server hasn't come up
|
|
# check if the server died while we were waiting
|
|
status = await spawner.poll()
|
|
if status is not None:
|
|
toc = IOLoop.current().time()
|
|
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
|
status=ServerSpawnStatus.failure
|
|
).observe(time.perf_counter() - spawn_start_time)
|
|
raise web.HTTPError(500, "Spawner failed to start [status=%s]. The logs for %s may contain details." % (
|
|
status, spawner._log_name))
|
|
|
|
if spawner._waiting_for_response:
|
|
# hit timeout waiting for response, but server's running.
|
|
# Hope that it'll show up soon enough,
|
|
# though it's possible that it started at the wrong URL
|
|
self.log.warning("User %s is slow to become responsive (timeout=%s)",
|
|
user_server_name, self.slow_spawn_timeout)
|
|
self.log.debug("Expecting server for %s at: %s",
|
|
user_server_name, spawner.server.url)
|
|
if spawner._proxy_pending:
|
|
# User.spawn finished, but it hasn't been added to the proxy
|
|
# Could be due to load or a slow proxy
|
|
self.log.warning("User %s is slow to be added to the proxy (timeout=%s)",
|
|
user_server_name, self.slow_spawn_timeout)
|
|
|
|
async def user_stopped(self, user, server_name):
|
|
"""Callback that fires when the spawner has stopped"""
|
|
spawner = user.spawners[server_name]
|
|
status = await spawner.poll()
|
|
if status is None:
|
|
status = 'unknown'
|
|
self.log.warning("User %s server stopped, with exit code: %s",
|
|
user.name, status,
|
|
)
|
|
await self.proxy.delete_user(user, server_name)
|
|
await user.stop(server_name)
|
|
|
|
async def stop_single_user(self, user, name=''):
|
|
if name not in user.spawners:
|
|
raise KeyError("User %s has no such spawner %r", user.name, name)
|
|
spawner = user.spawners[name]
|
|
if spawner.pending:
|
|
raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
|
|
# set user._stop_pending before doing anything async
|
|
# to avoid races
|
|
spawner._stop_pending = True
|
|
|
|
async def stop():
|
|
"""Stop the server
|
|
|
|
1. remove it from the proxy
|
|
2. stop the server
|
|
3. notice that it stopped
|
|
"""
|
|
tic = IOLoop.current().time()
|
|
try:
|
|
await self.proxy.delete_user(user, name)
|
|
await user.stop(name)
|
|
finally:
|
|
spawner._stop_pending = False
|
|
toc = IOLoop.current().time()
|
|
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
|
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
|
|
|
try:
|
|
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
|
|
except gen.TimeoutError:
|
|
# hit timeout, but stop is still pending
|
|
self.log.warning("User %s:%s server is slow to stop", user.name, name)
|
|
|
|
#---------------------------------------------------------------
|
|
# template rendering
|
|
#---------------------------------------------------------------
|
|
|
|
@property
|
|
def spawn_home_error(self):
|
|
"""Extra message pointing users to try spawning again from /hub/home.
|
|
|
|
Should be added to `self.extra_error_html` for any handler
|
|
that could serve a failed spawn message.
|
|
"""
|
|
home = url_path_join(self.hub.base_url, 'home')
|
|
return (
|
|
"You can try restarting your server from the "
|
|
"<a href='{home}'>home page</a>.".format(home=home)
|
|
)
|
|
|
|
def get_template(self, name):
|
|
"""Return the jinja template object for a given name"""
|
|
return self.settings['jinja2_env'].get_template(name)
|
|
|
|
def render_template(self, name, **ns):
|
|
template_ns = {}
|
|
template_ns.update(self.template_namespace)
|
|
template_ns.update(ns)
|
|
template = self.get_template(name)
|
|
return template.render(**template_ns)
|
|
|
|
@property
|
|
def template_namespace(self):
|
|
user = self.get_current_user()
|
|
ns = dict(
|
|
base_url=self.hub.base_url,
|
|
prefix=self.base_url,
|
|
user=user,
|
|
login_url=self.settings['login_url'],
|
|
login_service=self.authenticator.login_service,
|
|
logout_url=self.settings['logout_url'],
|
|
static_url=self.static_url,
|
|
version_hash=self.version_hash,
|
|
)
|
|
if self.settings['template_vars']:
|
|
ns.update(self.settings['template_vars'])
|
|
return ns
|
|
|
|
def write_error(self, status_code, **kwargs):
|
|
"""render custom error pages"""
|
|
exc_info = kwargs.get('exc_info')
|
|
message = ''
|
|
exception = None
|
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
|
if exc_info:
|
|
exception = exc_info[1]
|
|
# get the custom message, if defined
|
|
try:
|
|
message = exception.log_message % exception.args
|
|
except Exception:
|
|
pass
|
|
|
|
# construct the custom reason, if defined
|
|
reason = getattr(exception, 'reason', '')
|
|
if reason:
|
|
message = reasons.get(reason, reason)
|
|
|
|
if exception and isinstance(exception, SQLAlchemyError):
|
|
self.log.warning("Rolling back session due to database error %s", exception)
|
|
self.db.rollback()
|
|
|
|
# build template namespace
|
|
ns = dict(
|
|
status_code=status_code,
|
|
status_message=status_message,
|
|
message=message,
|
|
extra_error_html=getattr(self, 'extra_error_html', ''),
|
|
exception=exception,
|
|
)
|
|
|
|
self.set_header('Content-Type', 'text/html')
|
|
# allow setting headers from exceptions
|
|
# since exception handler clears headers
|
|
headers = getattr(exception, 'headers', None)
|
|
if headers:
|
|
for key, value in headers.items():
|
|
self.set_header(key, value)
|
|
|
|
# render the template
|
|
try:
|
|
html = self.render_template('%s.html' % status_code, **ns)
|
|
except TemplateNotFound:
|
|
self.log.debug("No template for %d", status_code)
|
|
html = self.render_template('error.html', **ns)
|
|
|
|
self.write(html)
|
|
|
|
|
|
class Template404(BaseHandler):
|
|
"""Render our 404 template"""
|
|
def prepare(self):
|
|
raise web.HTTPError(404)
|
|
|
|
|
|
class PrefixRedirectHandler(BaseHandler):
|
|
"""Redirect anything outside a prefix inside.
|
|
|
|
Redirects /foo to /prefix/foo, etc.
|
|
"""
|
|
def get(self):
|
|
uri = self.request.uri
|
|
if uri.startswith(self.base_url):
|
|
path = self.request.uri[len(self.base_url):]
|
|
else:
|
|
path = self.request.path
|
|
self.redirect(url_path_join(
|
|
self.hub.base_url, path,
|
|
), permanent=False)
|
|
|
|
|
|
class UserSpawnHandler(BaseHandler):
|
|
"""Redirect requests to /user/name/* handled by the Hub.
|
|
|
|
If logged in, spawn a single-user server and redirect request.
|
|
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
|
she will be redirected to /hub/user/bob/notebooks/mynotebook.ipynb,
|
|
which will be handled by this handler,
|
|
which will in turn send her to /user/alice/notebooks/mynotebook.ipynb.
|
|
"""
|
|
|
|
async def get(self, name, user_path):
|
|
if not user_path:
|
|
user_path = '/'
|
|
current_user = self.get_current_user()
|
|
if (
|
|
current_user
|
|
and current_user.name != name
|
|
and current_user.admin
|
|
and self.settings.get('admin_access', False)
|
|
):
|
|
# allow admins to spawn on behalf of users
|
|
user = self.find_user(name)
|
|
if user is None:
|
|
# no such user
|
|
raise web.HTTPError(404, "No such user %s" % name)
|
|
self.log.info("Admin %s requesting spawn on behalf of %s",
|
|
current_user.name, user.name)
|
|
admin_spawn = True
|
|
should_spawn = True
|
|
else:
|
|
user = current_user
|
|
admin_spawn = False
|
|
# For non-admins, we should spawn if the user matches
|
|
# otherwise redirect users to their own server
|
|
should_spawn = (current_user and current_user.name == name)
|
|
|
|
|
|
if should_spawn:
|
|
# if spawning fails for any reason, point users to /hub/home to retry
|
|
self.extra_error_html = self.spawn_home_error
|
|
|
|
# If people visit /user/:name directly on the Hub,
|
|
# the redirects will just loop, because the proxy is bypassed.
|
|
# Try to check for that and warn,
|
|
# though the user-facing behavior is unchanged
|
|
host_info = urlparse(self.request.full_url())
|
|
port = host_info.port
|
|
if not port:
|
|
port = 443 if host_info.scheme == 'https' else 80
|
|
if port != Server.from_url(self.proxy.public_url).connect_port and port == self.hub.connect_port:
|
|
self.log.warning("""
|
|
Detected possible direct connection to Hub's private ip: %s, bypassing proxy.
|
|
This will result in a redirect loop.
|
|
Make sure to connect to the proxied public URL %s
|
|
""", self.request.full_url(), self.proxy.public_url)
|
|
|
|
# logged in as valid user, check for pending spawn
|
|
spawner = user.spawner
|
|
|
|
# First, check for previous failure.
|
|
if (
|
|
not spawner.active
|
|
and spawner._spawn_future
|
|
and spawner._spawn_future.done()
|
|
and spawner._spawn_future.exception()
|
|
):
|
|
# Condition: spawner not active and _spawn_future exists and contains an Exception
|
|
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
|
# We should point the user to Home if the most recent spawn failed.
|
|
exc = spawner._spawn_future.exception()
|
|
self.log.error("Preventing implicit spawn for %s because last spawn failed: %s",
|
|
spawner._log_name, exc)
|
|
# raise a copy because each time an Exception object is re-raised, its traceback grows
|
|
raise copy.copy(exc).with_traceback(exc.__traceback__)
|
|
|
|
# check for pending spawn
|
|
if spawner.pending and spawner._spawn_future:
|
|
# wait on the pending spawn
|
|
self.log.debug("Waiting for %s pending %s", spawner._log_name, spawner.pending)
|
|
try:
|
|
await gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), spawner._spawn_future)
|
|
except gen.TimeoutError:
|
|
self.log.info("Pending spawn for %s didn't finish in %.1f seconds", spawner._log_name, self.slow_spawn_timeout)
|
|
pass
|
|
|
|
# we may have waited above, check pending again:
|
|
if spawner.pending:
|
|
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
|
# spawn has started, but not finished
|
|
self.statsd.incr('redirects.user_spawn_pending', 1)
|
|
url_parts = []
|
|
html = self.render_template(
|
|
"spawn_pending.html",
|
|
user=user,
|
|
progress_url=spawner._progress_url,
|
|
)
|
|
self.finish(html)
|
|
return
|
|
|
|
# spawn has supposedly finished, check on the status
|
|
if spawner.ready:
|
|
status = await spawner.poll()
|
|
else:
|
|
status = 0
|
|
|
|
# server is not running, trigger spawn
|
|
if status is not None:
|
|
if spawner.options_form:
|
|
url_parts = [self.hub.base_url, 'spawn']
|
|
if current_user.name != user.name:
|
|
# spawning on behalf of another user
|
|
url_parts.append(user.name)
|
|
self.redirect(url_concat(url_path_join(*url_parts),
|
|
{'next': self.request.uri}))
|
|
return
|
|
else:
|
|
await self.spawn_single_user(user)
|
|
|
|
# spawn didn't finish, show pending page
|
|
if spawner.pending:
|
|
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
|
# spawn has started, but not finished
|
|
self.statsd.incr('redirects.user_spawn_pending', 1)
|
|
html = self.render_template(
|
|
"spawn_pending.html",
|
|
user=user,
|
|
progress_url=spawner._progress_url,
|
|
)
|
|
self.finish(html)
|
|
return
|
|
|
|
# We do exponential backoff here - since otherwise we can get stuck in a redirect loop!
|
|
# This is important in many distributed proxy implementations - those are often eventually
|
|
# consistent and can take up to a couple of seconds to actually apply throughout the cluster.
|
|
try:
|
|
redirects = int(self.get_argument('redirects', 0))
|
|
except ValueError:
|
|
self.log.warning("Invalid redirects argument %r", self.get_argument('redirects'))
|
|
redirects = 0
|
|
|
|
# check redirect limit to prevent browser-enforced limits.
|
|
# In case of version mismatch, raise on only two redirects.
|
|
if redirects >= self.settings.get(
|
|
'user_redirect_limit', 4
|
|
) or (redirects >= 2 and spawner._jupyterhub_version != __version__):
|
|
# We stop if we've been redirected too many times.
|
|
msg = "Redirect loop detected."
|
|
if spawner._jupyterhub_version != __version__:
|
|
msg += (
|
|
" Notebook has jupyterhub version {singleuser}, but the Hub expects {hub}."
|
|
" Try installing jupyterhub=={hub} in the user environment"
|
|
" if you continue to have problems."
|
|
).format(
|
|
singleuser=spawner._jupyterhub_version or 'unknown (likely < 0.8)',
|
|
hub=__version__,
|
|
)
|
|
raise web.HTTPError(500, msg)
|
|
|
|
without_prefix = self.request.uri[len(self.hub.base_url):]
|
|
target = url_path_join(self.base_url, without_prefix)
|
|
if self.subdomain_host:
|
|
target = user.host + target
|
|
|
|
# record redirect count in query parameter
|
|
if redirects:
|
|
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
|
# add capped exponential backoff where cap is 10s
|
|
await gen.sleep(min(1 * (2 ** redirects), 10))
|
|
# rewrite target url with new `redirects` query value
|
|
url_parts = urlparse(target)
|
|
query_parts = parse_qs(url_parts.query)
|
|
query_parts['redirects'] = redirects + 1
|
|
url_parts = url_parts._replace(query=urlencode(query_parts))
|
|
target = urlunparse(url_parts)
|
|
else:
|
|
target = url_concat(target, {'redirects': 1})
|
|
|
|
self.redirect(target)
|
|
self.statsd.incr('redirects.user_after_login')
|
|
elif current_user:
|
|
# logged in as a different user, redirect
|
|
self.statsd.incr('redirects.user_to_user', 1)
|
|
target = url_path_join(current_user.url, user_path or '')
|
|
if self.request.query:
|
|
# FIXME: use urlunparse instead?
|
|
target += '?' + self.request.query
|
|
self.redirect(target)
|
|
else:
|
|
# not logged in, clear any cookies and reload
|
|
self.statsd.incr('redirects.user_to_login', 1)
|
|
self.clear_login_cookie()
|
|
self.redirect(url_concat(
|
|
self.settings['login_url'],
|
|
{'next': self.request.uri},
|
|
))
|
|
|
|
|
|
class UserRedirectHandler(BaseHandler):
|
|
"""Redirect requests to user servers.
|
|
|
|
Allows public linking to "this file on your server".
|
|
|
|
/user-redirect/path/to/foo will redirect to /user/:name/path/to/foo
|
|
|
|
If the user is not logged in, send to login URL, redirecting back here.
|
|
|
|
.. versionadded:: 0.7
|
|
"""
|
|
@web.authenticated
|
|
def get(self, path):
|
|
user = self.get_current_user()
|
|
url = url_path_join(user.url, path)
|
|
if self.request.query:
|
|
# FIXME: use urlunparse instead?
|
|
url += '?' + self.request.query
|
|
self.redirect(url)
|
|
|
|
|
|
class CSPReportHandler(BaseHandler):
|
|
'''Accepts a content security policy violation report'''
|
|
@web.authenticated
|
|
def post(self):
|
|
'''Log a content security policy violation report'''
|
|
self.log.warning(
|
|
"Content security violation: %s",
|
|
self.request.body.decode('utf8', 'replace')
|
|
)
|
|
# Report it to statsd as well
|
|
self.statsd.incr('csp_report')
|
|
|
|
|
|
class AddSlashHandler(BaseHandler):
|
|
"""Handler for adding trailing slash to URLs that need them"""
|
|
def get(self, *args):
|
|
src = urlparse(self.request.uri)
|
|
dest = src._replace(path=src.path + '/')
|
|
self.redirect(urlunparse(dest))
|
|
|
|
default_handlers = [
|
|
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
|
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
|
(r'/security/csp-report', CSPReportHandler),
|
|
]
|