Compare commits

..

106 Commits
0.5.0 ... 0.6.0

Author SHA1 Message Date
Min RK
3046971064 release 0.6 2016-04-25 14:10:29 +02:00
Min RK
30498f97c4 Merge pull request #543 from robnagler:master
Allow jupyterhub-singleuser to run on python 2 install

closes #543
2016-04-25 11:35:40 +02:00
robnagler
d9d68efa55 run with default python, which might be python 2 2016-04-25 11:31:17 +02:00
Min RK
4125dc7ad0 Merge pull request #542 from willingc/doc-addition
Add troubleshooting documentation for 500 issue
2016-04-22 15:23:28 +02:00
Carol Willing
13600894fb Changed link re: Min's tip 2016-04-22 06:11:53 -07:00
Carol Willing
1b796cd871 Add links 2016-04-22 05:37:30 -07:00
Carol Willing
e7889dc12e Add 500 error to troubleshooting docs 2016-04-22 05:36:15 -07:00
Carol Willing
244a3b1000 Merge pull request #541 from minrk/cookie-referer
check referer only if there is a valid user cookie
2016-04-22 05:03:05 -07:00
Carol Willing
05dfda469f Merge pull request #540 from minrk/0.6
Changelog for 0.6
2016-04-22 04:21:44 -07:00
Min RK
6b19ee792d check referer only if there is a valid user cookie
avoids misleading "Blocking Cross Origin..." message
when there's no logged-in user for API requests.
2016-04-22 13:16:13 +02:00
Min RK
ace38d744a Changelog for 0.6 2016-04-22 12:50:49 +02:00
Min RK
56a5ed8c87 Merge pull request #539 from minrk/unused-email
add ignored -e arg to docker login
2016-04-22 10:50:15 +02:00
Min RK
60e8a76476 add ignored -e arg to docker login
doesn't appear to be needed on more recent docker
2016-04-22 10:24:40 +02:00
Min RK
552800ceb7 add sec doc
reviewed on security list
2016-04-22 10:20:18 +02:00
Carol Willing
7dd1900f5f Merge pull request #521 from minrk/docker-onbuild
Move docker onbuild step to jupyterhub-onbuild
2016-04-21 18:26:43 -07:00
Min RK
35c261d0ed better Dockerfile comments from Carol 2016-04-21 21:32:36 +02:00
Min RK
fa34ce64b7 include dockerfiles in manifest 2016-04-21 13:50:35 +02:00
Min RK
f0504420a9 move docker onbuild to directory 2016-04-21 13:50:27 +02:00
Min RK
8666f3a46c push onbuild image to docker hub with circle-ci 2016-04-21 13:38:15 +02:00
Carol Willing
60d6019cf7 Merge pull request #534 from jupyterhub/willingc-patch-1
Fix post move links to jupyterhub org in README
2016-04-20 20:05:39 -07:00
Carol Willing
173daeeb09 Fix post move links to jupyterhub org in README 2016-04-20 20:00:18 -07:00
Carol Willing
cf988dca4d Merge pull request #531 from minrk/extra-log-file-doc
[DOC] Note that extra_log_file only affects Hub's logs
2016-04-20 06:00:01 -07:00
Min RK
ffc2faabf7 [DOC] Note that extra_log_file only affects Hub's logs
not single-user server logs, or anything else.
2016-04-20 14:45:22 +02:00
Min RK
9fed0334c8 jupyterhub path in dockerfiles 2016-04-20 14:43:25 +02:00
Min RK
8b61eb7347 install from miniconda 4.0.5
- checksum miniconda installer
- move jupyterhub src to /src/jupyterhub
2016-04-19 13:21:25 +02:00
Min RK
9cdda101c7 Move onbuild step to jupyterhub-onbuild
Removes onbuild from from jupyter/jupyterhub image,
though it remains incomplete and will not run without a config file.
2016-04-19 13:21:24 +02:00
Carol Willing
f3bbca80ea Merge pull request #528 from minrk/test-cookie-secret
exercise cookie secret loading in tests
2016-04-19 04:01:36 -07:00
Carol Willing
ce30f28449 Merge pull request #527 from minrk/polish-520
polish cookie-secret PR
2016-04-19 03:56:41 -07:00
Min RK
6cb58c17e7 exercise cookie secret loading in tests 2016-04-19 11:15:48 +02:00
Min RK
183e244490 polish cookie-secret PR
- fix a couple of typos
- use ValueError instead of assert to ensure error is raised even when Python optimizes-out asserts
2016-04-19 10:15:10 +02:00
Min RK
d5cd5115a5 Merge pull request #520 from robnagler/master
cookie_secret file must be base64
2016-04-19 10:10:58 +02:00
robnagler
bbd3b22490 incorrect log call in previous checkin 2016-04-18 16:25:03 +00:00
robnagler
e02daf01ad Fix jupyter/jupyterhub#520: exit if any errors parsing file; Also, fix abstraction use of getenv/os.environ (use one or the other, not both) 2016-04-18 15:35:31 +00:00
robnagler
af1e253f8a Fix jupyter/jupyterhub#522 2016-04-18 15:16:01 +00:00
Min RK
491da69994 typo 2016-04-18 12:51:35 +02:00
Min RK
0737600d3c Merge pull request #515 from proversity-org/master
Post handler for  requesting authorization tokens; authenticated via form.
2016-04-18 11:23:24 +02:00
dominic
c7f542e79e Add tests for form based token generation 2016-04-18 10:27:38 +02:00
robnagler
21213c97c6 cookie_secret file is decoded by binascii.a2b_base64 so need to document it must be Base64. Added better doc for other values, and included description of "cookie_secret" parameter as well 2016-04-17 23:35:06 +00:00
Carol Willing
b36cd92ae6 Merge pull request #517 from minrk/load-tokens
allow pre-loading API tokens from config
2016-04-15 06:49:40 -07:00
Min RK
094ac451c7 Don't allow bad tokens to create tokens in the db 2016-04-15 12:42:52 +02:00
Min RK
fa4b666693 allow pre-loading API tokens from config
This is the first small part of easing the pain of services,
which is generating the API tokens,
and used to require initializing the JupyterHub database.
2016-04-14 16:45:40 +02:00
Carol Willing
ce9dc2093c Merge pull request #514 from minrk/docker-readme
revisions to docker notes
2016-04-14 07:05:02 -07:00
dominic
9fd97a8d63 Keep line spacing consistent. Don't do anything if authenticator not defined. 2016-04-14 15:39:26 +02:00
Min RK
2261a0e21d revisions to docker notes
- link to Docker docs on volumes
- name container `jupyterhub`
- wording
2016-04-14 15:35:58 +02:00
dominic
a7a1c32a03 Add post handler for form based auth 2016-04-14 09:32:42 +02:00
Matthias Bussonnier
dfd01bbf5f Merge pull request #503 from minrk/disable-npm-progress
disable npm progress when installing
2016-04-08 17:36:02 -07:00
Min RK
b11a5be781 disable npm progress when installing
apparently faster, but should also fix unicode errors
2016-04-08 16:35:23 -07:00
Kyle Kelley
8b6950055b Merge pull request #501 from minrk/set-login-at-root-redirect
set login cookie when redirecting
2016-04-06 12:35:50 -05:00
Min RK
e8a298be00 set login cookie when redirecting
should avoid one possible redirect-loop case when the single-user cookie is invalid, but the Hub cookie is valid.
2016-04-06 10:18:23 -07:00
Min RK
69f24acac2 Merge pull request #499 from yuvipanda/statsd
Emit metrics via statsd
2016-04-05 09:23:20 -07:00
YuviPanda
9ffebd0c5e Send metrics about various redirects from User spawning 2016-04-01 14:05:02 -07:00
YuviPanda
2dd3d3c448 Send timing info about spawner success / failure 2016-04-01 10:20:37 -07:00
YuviPanda
4644e7019e Send metrics about running and active users
Uses the standard user last-updated activity callback
2016-04-01 10:20:37 -07:00
YuviPanda
5a15d7a219 Actually start the timer 2016-04-01 10:20:37 -07:00
YuviPanda
788129da12 Send metrics for login and logout actions 2016-04-01 10:20:37 -07:00
YuviPanda
cac5175c9b Send CSP metrics to statsd 2016-04-01 10:20:37 -07:00
YuviPanda
80556360ac Add statsd to the base request handler 2016-04-01 10:20:37 -07:00
YuviPanda
3dca0df55f Add statsd to the base JupyterHub app
Not actually emitting any metrics yet
2016-04-01 10:20:37 -07:00
Min RK
62a5e9dbce Merge pull request #497 from yuvipanda/env-callable
Allow environment config values to be callable
2016-04-01 09:57:52 -07:00
YuviPanda
45fcdc75c0 Add docs about callables in environment configurable 2016-03-31 23:44:08 -07:00
Min RK
f1bdf6247a Merge pull request #500 from yuvipanda/fix-url-encoding-4
Use User.url instead of constructing it manually
2016-03-31 22:15:58 -07:00
YuviPanda
80932a51f4 Use User.url instead of constructing it manually
This fixes issues with URL encoding when redirecting users to
their own notebook instances
2016-03-31 17:28:33 -07:00
Min RK
c8774c44d4 Merge pull request #498 from yuvipanda/statsd-configurable
Mark statsd_prefix as configurable as well
2016-03-31 15:08:10 -07:00
YuviPanda
bf2629450c Mark statsd_prefix as configurable as well 2016-03-31 13:46:37 -07:00
YuviPanda
705ff78715 Allow environment config values to be callable
This allows deployments to configure environment variables
that need to be different for each user / container (such as
credentials for various services, etc).
2016-03-31 11:52:53 -07:00
Min RK
a13119a79f Merge pull request #496 from yuvipanda/statsd
Allow specifying statsd host/port/prefix info
2016-03-31 11:18:09 -07:00
YuviPanda
6932719e4e Convert port into string (so that .join works) 2016-03-31 10:32:49 -07:00
YuviPanda
68a750fc7a Use 'Integer' rather than 'Int' for config traitlet 2016-03-30 19:04:57 -07:00
YuviPanda
c6d05d0840 Allow specifying statsd host/port/prefix info
Currently only passes it through to CHP. This is needed
for the cases when JupyterHub spawns and maintains CHP.
2016-03-30 18:59:32 -07:00
Carol Willing
2bbfd75f4d Merge pull request #495 from Carreau/add-import
Import warnings, used on line 215, not imported.
2016-03-29 15:52:39 -07:00
Matthias Bussonnier
26f0e8ea5c Import warnings, used on line 215, not imported. 2016-03-29 15:36:22 -07:00
Carol Willing
552e5caa11 Merge pull request #494 from jupyter/Codecov-badge
Add codecov Badge.
2016-03-29 15:23:54 -07:00
Matthias Bussonnier
7753187e51 Add codecov Badge. 2016-03-29 15:04:17 -07:00
Carol Willing
bddadc7522 Merge pull request #493 from minrk/traitlets-4-1-again
use traitlets 4.1 APIs
2016-03-29 14:51:51 -07:00
Min RK
195eea55f3 log.warning 2016-03-29 09:22:32 -07:00
Min RK
7a2794af7c use traitlets-4.1 observe/default decorators 2016-03-27 10:41:36 -07:00
Min RK
fa48620076 use traitlets-4.1 .tag(config=True) API 2016-03-27 10:29:36 -07:00
Min RK
e4cfe01c4a require traitlets 4.1 2016-03-27 10:21:41 -07:00
Carol Willing
b35e506220 Merge pull request #479 from minrk/config-env
Make Spawner.env configurable
2016-03-24 07:59:44 -07:00
Carol Willing
dd3ed1bf75 Merge pull request #490 from minrk/disable-pam-session
Allow disabling PAM sessions
2016-03-24 07:57:00 -07:00
Min RK
40368b8f55 Allow disabling PAM sessions
it's often buggy and rarely necessary,
so allow it to be disabled when it's causing problems.

It's still on by default for backward-compatibility,
though maybe it shouldn't be.
2016-03-23 23:24:54 +01:00
Min RK
d0f1520642 Add Spawner.environment configurable
instead of making existing Spawner.env configurable

Spawner.env is deprecated
2016-03-22 13:48:26 +01:00
Carol Willing
28c8265c3d Merge pull request #487 from minrk/fix-failed-login-for-none
Fix 'failed login for None' message
2016-03-21 04:51:20 -07:00
Min RK
1d1a8ba78b Fix 'failed login for None' message
on failed login, get username from form data, not the guaranteed-None return value of authenticate
2016-03-21 12:01:31 +01:00
Min RK
a1c764593c travis_retry tests
to hide intermittent failures and enable laziness
2016-03-15 10:37:03 +01:00
Min RK
06902afa2d Merge pull request #481 from willingc/issue-417
Add additional documentation on --no-SSL option
2016-03-15 10:12:18 +01:00
Min RK
6d46f10cfa Merge pull request #480 from willingc/issue-458
Update the configuration section of docs
2016-03-15 10:11:54 +01:00
Carol Willing
b71f34eb3c Fix transposed version number 2016-03-14 16:57:12 -07:00
Carol Willing
11df935f34 Fix awkward wording 2016-03-14 16:54:04 -07:00
Carol Willing
19b6468889 Add no-SSL option to docs 2016-03-14 16:48:49 -07:00
Carol Willing
d2dddd6c82 Update the configuration section of docs, add example 2016-03-14 16:21:24 -07:00
Min RK
5d140fb889 Merge pull request #478 from willingc/readme-docker
Update README re: docker image contents
2016-03-11 22:24:27 +01:00
Matthias Bussonnier
2bf8683905 Merge pull request #477 from willingc/doc-sphinx
Use latest version of Sphinx to fix RTD "Edit on GitHub"
2016-03-11 10:46:52 -08:00
Carol Willing
2dba7f4f61 Update README re: docker image contents 2016-03-11 10:05:13 -08:00
Carol Willing
2820ba319f Update sphinx version for md on rtd 2016-03-11 07:55:49 -08:00
Min RK
be7a627c11 Make Spawner.env configurable
moves `_env_default` logic to `get_env`,
so that `Spawner.env` can be safely configurable
2016-03-11 12:34:49 +01:00
Matthias Bussonnier
2cb1618937 Merge pull request #467 from minrk/add-user-more-often
Call `add_user` more often
2016-03-10 14:45:56 -08:00
Min RK
c9e0c5fe04 Merge pull request #474 from minrk/user.url
allow user.url to be accessed without the server running
2016-03-10 10:28:11 +01:00
Min RK
922956def2 allow user.url to be accessed without the server running
Reduces the number of different ways we need to build the same URLs.
2016-03-09 09:30:50 +01:00
Min RK
c6c699ea89 Merge pull request #472 from yuvipanda/fix-user-encoding
Use encoded URL when redirecting user notebooks
2016-03-09 09:20:43 +01:00
YuviPanda
e0219d0363 Use encoded URL when redirecting user notebooks
Otherwise it breaks for usernames that have url unsafe
characters.
2016-03-08 18:41:35 -08:00
Matthias Bussonnier
f7dab558e4 Merge pull request #468 from minrk/clean-the-pool
set default pool_recycle if using mysql
2016-03-08 11:28:59 -08:00
Min RK
74e558dad2 set default pool_recycle if using mysql 2016-03-08 10:58:18 +01:00
Min RK
96269fac0f Call add_user more often
- Ensures add_user is called as part of startup *for all users*.
  This was previously only true for users not already in the db.
- Normalize usernames in whitelist and admin sets
- Call add_user on new users logged in when there is no whitelist.
2016-03-08 10:49:02 +01:00
Min RK
a0501c6ee4 set patch version to 0 on release 2016-03-08 09:55:44 +01:00
Min RK
ea2ed75ab2 back to dev 2016-03-08 09:00:41 +01:00
40 changed files with 1113 additions and 493 deletions

View File

@@ -12,7 +12,7 @@ before_install:
install:
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
script:
- py.test --cov jupyterhub jupyterhub/tests -v
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
after_success:
- codecov
matrix:

View File

@@ -1,12 +1,27 @@
# A base docker image that includes juptyerhub and IPython master
# An incomplete base Docker image for running JupyterHub
#
# Build your own derivative images starting with
# Add your configuration to create a complete derivative Docker image.
#
# FROM jupyter/jupyterhub:latest
# Include your configuration settings by starting with one of two options:
#
# Option 1:
#
# FROM jupyterhub/jupyterhub:latest
#
# And put your configuration file jupyterhub_config.py in /srv/jupyterhub/jupyterhub_config.py.
#
# Option 2:
#
# Or you can create your jupyterhub config and database on the host machine, and mount it with:
#
# docker run -v $PWD:/srv/jupyterhub -t jupyterhub/jupyterhub
#
# NOTE
# If you base on jupyterhub/jupyterhub-onbuild
# your jupyterhub_config.py will be added automatically
# from your docker directory.
FROM debian:jessie
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
# install nodejs, utf8 locale
@@ -22,7 +37,8 @@ RUN apt-get -y update && \
ENV LANG C.UTF-8
# install Python with conda
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.0.5-Linux-x86_64.sh -O /tmp/miniconda.sh && \
echo 'a7bcd0425d8b6688753946b59681572f63c2241aed77bf0ec6de4c5edc5ceeac */tmp/miniconda.sh' | shasum -a 256 -c - && \
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
/opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \
/opt/conda/bin/pip install --upgrade pip && \
@@ -32,21 +48,16 @@ ENV PATH=/opt/conda/bin:$PATH
# install js dependencies
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
WORKDIR /srv/
ADD . /srv/jupyterhub
WORKDIR /srv/jupyterhub/
ADD . /src/jupyterhub
WORKDIR /src/jupyterhub
RUN python setup.py js && pip install . && \
rm -rf node_modules ~/.cache ~/.npm
rm -rf $PWD ~/.cache ~/.npm
RUN mkdir -p /srv/jupyterhub/
WORKDIR /srv/jupyterhub/
# Derivative containers should add jupyterhub config,
# which will be used when starting the application.
EXPOSE 8000
LABEL org.jupyter.service="jupyterhub"
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
CMD ["jupyterhub"]

View File

@@ -4,7 +4,9 @@ include setupegg.py
include bower.json
include package.json
include *requirements.txt
include Dockerfile
graft onbuild
graft jupyterhub
graft scripts
graft share

View File

@@ -3,9 +3,11 @@
Questions, comments? Visit our Google Group:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter)
[![Build Status](https://travis-ci.org/jupyter/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyter/jupyterhub)
[![Circle CI](https://circleci.com/gh/jupyter/jupyterhub.svg?style=shield&circle-token=b5b65862eb2617b9a8d39e79340b0a6b816da8cc)](https://circleci.com/gh/jupyter/jupyterhub)
[![Build Status](https://travis-ci.org/jupyterhub/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyterhub/jupyterhub)
[![Circle CI](https://circleci.com/gh/jupyterhub/jupyterhub.svg?style=shield&circle-token=b5b65862eb2617b9a8d39e79340b0a6b816da8cc)](https://circleci.com/gh/jupyterhub/jupyterhub)
[![Documentation Status](https://readthedocs.org/projects/jupyterhub/badge/?version=latest)](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
[![codecov.io](https://codecov.io/github/jupyter/jupyterhub/coverage.svg?branch=master)](https://codecov.io/github/jupyter/jupyterhub?branch=master)
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
@@ -119,19 +121,24 @@ Some examples, meant as illustration and testing of this concept:
### Docker
There is a ready to go [docker image](https://hub.docker.com/r/jupyter/jupyterhub/).
It can be started with the following command:
There is a ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyter/jupyterhub/).
[Note: This `jupyter/jupyterhub` docker image is only an image for running the Hub service itself.
It does not require the other Jupyter components, which are needed by the single-user servers.
To run the single-user servers, which may be on the same system as the Hub or not, installation of Jupyter Notebook ≥ 4 is required.]
docker run -d --name jupyter.cont [-v /home/jupyter-home:/home] jupyter/jupyterhub jupyterhub
The JupyterHub docker image can be started with the following command:
This command will create a container named `jupyter.cont` that you can stop and resume with `docker stop/start`.
It will be listening on all interfaces at port 8000. So this is perfect to test docker on your desktop or laptop.
docker run -d --name jupyterhub jupyter/jupyterhub jupyterhub
This command will create a container named `jupyterhub` that you can stop and resume with `docker stop/start`.
It will be listening on all interfaces at port 8000, so this is perfect to test JupyterHub on your desktop or laptop.
If you want to run docker on a computer that has a public IP then you should (as in MUST) secure it with ssl by
adding ssl options to your docker configuration or using a ssl enabled proxy. The `-v/--volume` option will
adding ssl options to your docker configuration or using a ssl enabled proxy.
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/) will
allow you to store data outside the docker image (host system) so it will be persistent, even when you start
a new image. The command `docker exec -it jupyter.cont bash` will spawn a root shell in your started docker
a new image. The command `docker exec -it jupyterhub bash` will spawn a root shell in your docker
container. You can use it to create system users in the container. These accounts will be used for authentication
in jupyterhub's default configuration. In order to run without SSL, you'll need to set `--no-ssl` explicitly.
in jupyterhub's default configuration. In order to run without SSL (for testing purposes only), you'll need to set `--no-ssl` explicitly.
# Getting help

View File

@@ -8,4 +8,12 @@ dependencies:
test:
override:
- docker build -t jupyter/jupyterhub .
- docker build -t jupyterhub/jupyterhub .
- docker build -t jupyterhub/jupyterhub-onbuild:${CIRCLE_TAG:-latest} onbuild
deployment:
hub:
branch: master
commands:
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
- docker push jupyterhub/jupyterhub-onbuild:${CIRCLE_TAG:-latest}

View File

@@ -1,3 +1,3 @@
-r ../requirements.txt
sphinx
sphinx>=1.3.6
recommonmark==0.4.0

View File

@@ -2,6 +2,20 @@
See `git log` for a more detailed summary.
## 0.6
- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc.
- `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this
- Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}`
- Update to traitlets 4.1 `@default`, `@observe` APIs for traits
- Allow disabling PAM sessions via `c.PAMAuthenticator.open_sessions = False`. This may be needed on SELinux-enabled systems, where our PAM session logic often does not work properly
- Add `Spawner.environment` configurable, for defining extra environment variables to load for single-user servers
- JupyterHub API tokens can be pregenerated and loaded via `JupyterHub.api_tokens`, a dict of `token: username`.
- JupyterHub API tokens can be requested via the REST API, with a POST request to `/api/authorizations/token`.
This can only be used if the Authenticator has a username and password.
- Various fixes for user URLs and redirects
## 0.5

View File

@@ -22,9 +22,11 @@ There are three main categories of processes run by the `jupyterhub` command lin
## JupyterHub's default behavior
**IMPORTANT:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
**IMPORTANT:** In its default configuration, JupyterHub requires SSL encryption (HTTPS) to run.
**You should not run JupyterHub without SSL encryption on a public network.**
See [Security documentation](#Security) for how to configure JupyterHub to use SSL.
See [Security documentation](#Security) for how to configure JupyterHub to use SSL, and in
certain cases, e.g. behind SSL termination in nginx, allowing the hub to run with no SSL
by requiring `--no-ssl` (as of [version 0.5](./changelog.html)).
To start JupyterHub in its default configuration, type the following at the command line:
@@ -64,23 +66,13 @@ The location of these files can be specified via configuration, discussed below.
JupyterHub is configured in two ways:
1. Command-line arguments
2. Configuration files
1. Configuration file
2. Command-line arguments
Type the following for brief information about the command line arguments:
jupyterhub -h
or:
jupyterhub --help-all
for the full command line help.
By default, JupyterHub will look for a configuration file (can be missing)
### Configuration file
By default, JupyterHub will look for a configuration file (which may not be created yet)
named `jupyterhub_config.py` in the current working directory.
You can create an empty configuration file with
You can create an empty configuration file with:
jupyterhub --generate-config
@@ -92,6 +84,23 @@ values. You can load a specific config file with:
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
on the config system Jupyter uses.
### Command-line arguments
Type the following for brief information about the command-line arguments:
jupyterhub -h
or:
jupyterhub --help-all
for the full command line help.
All configurable options are technically configurable on the command-line,
even if some are really inconvenient to type. Just replace the desired option,
c.Class.trait, with --Class.trait. For example, to configure
c.Spawner.notebook_dir = '~/assignments' from the command-line:
jupyterhub --Spawner.notebook_dir='~/assignments'
## Networking
@@ -147,7 +156,7 @@ c.JupyterHub.hub_port = 54321
## Security
**IMPORTANT:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
**IMPORTANT:** In its default configuration, JupyterHub requires SSL encryption (HTTPS) to run.
**You should not run JupyterHub without SSL encryption on a public network.**
Security is the most important aspect of configuring Jupyter. There are three main aspects of the
@@ -181,6 +190,10 @@ Some cert files also contain the key, in which case only the cert is needed. It
these files be put in a secure location on your server, where they are not readable by regular
users.
Note: In certain cases, e.g. behind SSL termination in nginx, allowing no SSL
running on the hub may be desired. To run the Hub without SSL, you must opt
in by configuring and confirming the `--no-ssl` option, added as of [version 0.5](./changelog.html).
## Cookie secret
The cookie secret is an encryption key, used to encrypt the browser cookies used for
@@ -192,26 +205,36 @@ as follows:
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
```
The content of this file should be a long random string. An example would be to generate this
file as:
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
```bash
openssl rand -hex 1024 > /srv/jupyterhub/cookie_secret
openssl rand -base64 2048 > /srv/jupyterhub/cookie_secret
```
In most deployments of JupyterHub, you should point this to a secure location on the file
system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when
the Hub starts, a new cookie secret is generated and stored in the file. The recommended
permissions for the cookie secret file should be 600 (owner-only rw).
the Hub starts, a new cookie secret is generated and stored in the file. The
file must not be readable by group or other or the server won't start.
The recommended permissions for the cookie secret file are 600 (owner-only rw).
If you would like to avoid the need for files, the value can be loaded in the Hub process from
the `JPY_COOKIE_SECRET` environment variable:
the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You
can set it this way:
```bash
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
```
For security reasons, this environment variable should only be visible to the Hub.
If you set it dynamically as above, all users will be logged out each time the
Hub starts.
You can also set the secret in the configuration file itself as a binary string:
```python
c.JupyterHub.cookie_secret = bytes.fromhex('VERY LONG SECRET HEX STRING')
```
## Proxy authentication token

View File

@@ -46,6 +46,7 @@ Contents:
getting-started
howitworks
websecurity
.. toctree::
:maxdepth: 2

View File

@@ -2,10 +2,98 @@
This document is under active development.
## Networking
When troubleshooting, you may see unexpected behaviors or receive an error
message. These two lists provide links to identifying the cause of the
problem and how to resolve it.
If JupyterHub proxy fails to start:
## Behavior problems
- [JupyterHub proxy fails to start](#jupyterhub-proxy-fails-to-start)
## Errors
- [500 error after spawning a single-user server](#500-error-after-spawning-my-single-user-server)
----
## JupyterHub proxy fails to start
If you have tried to start the JupyterHub proxy and it fails to start:
- check if the JupyterHub IP configuration setting is
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
- Try starting with ``jupyterhub --ip=0.0.0.0``
----
## 500 error after spawning my single-user server
You receive a 500 error when accessing the URL `/user/you/...`. This is often
seen when your single-user server cannot check your cookies with the Hub.
There are two likely reasons for this:
1. The single-user server cannot connect to the Hub's API (networking
configuration problems)
2. The single-user server cannot *authenticate* its requests (invalid token)
### Symptoms:
The main symptom is a failure to load *any* page served by the single-user
server, met with a 500 error. This is typically the first page at `/user/you`
after logging in or clicking "Start my server". When a single-user server
receives a request, it makes an API request to the Hub to check if the cookie
corresponds to the right user. This request is logged.
If everything is working, it will look like this:
```
200 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 6.10ms
```
You should see a similar 200 message, as above, in the Hub log when you first
visit your single-user server. If you don't see this message in the log, it
may mean that your single-user server isn't connecting to your Hub.
If you see 403 (forbidden) like this, it's a token problem:
```
403 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 4.14ms
```
Check the logs of the single-user server, which may have more detailed
information on the cause.
### Causes and resolutions:
#### No authorization request
If you make an API request and it is not received by the server, you likely
have a network configuration issue. Often, this happens when the Hub is only
listening on 127.0.0.1 (default) and the single-user servers are not on the
same 'machine' (can be physically remote, or in a docker container or VM). The
fix for this case is to make sure that `c.JupyterHub.hub_ip` is an address
that all single-user servers can connect to, e.g.:
```python
c.JupyterHub.hub_ip = '10.0.0.1'
```
#### 403 GET /hub/api/authorizations/cookie
If you receive a 403 error, the API token for the single-user server is likely
invalid. Commonly, the 403 error is caused by resetting the JupyterHub
database (either removing jupyterhub.sqlite or some other action) while
leaving single-user servers running. This happens most frequently when using
DockerSpawner, because Docker's default behavior is to stop/start containers
which resets the JupyterHub database, rather than destroying and recreating
the container every time. This means that the same API token is used by the
server for its whole life, until the container is rebuilt.
The fix for this Docker case is to remove any Docker containers seeing this
issue (typicaly all containers created before a certain point in time):
docker rm -f jupyter-name
After this, when you start your server via JupyterHub, it will build a
new container. If this was the underlying cause of the issue, you should see
your server again.

View File

@@ -0,0 +1,63 @@
# Web Security in JupyterHub
JupyterHub is designed to be a simple multi-user server for modestly sized groups of semi-trusted users.
While the design reflects serving semi-trusted users,
JupyterHub is not necessarily unsuitable for serving untrusted users.
Using JupyterHub with untrusted users does mean more work and much care is required to secure a Hub against untrusted users,
with extra caution on protecting users from each other as the Hub is serving untrusted users.
One aspect of JupyterHub's design simplicity for semi-trusted users is that the Hub and single-user servers are placed in a single domain, behind a [proxy][configurable-http-proxy].
As a result, if the Hub is serving untrusted users,
many of the web's cross-site protections are not applied between single-user servers and the Hub,
or between single-user servers and each other,
since browsers see the whole thing (proxy, Hub, and single user servers) as a single website.
To protect users from each other, a user must never be able to write arbitrary HTML and serve it to another user on the Hub's domain.
JupyterHub's authentication setup prevents this because only the owner of a given single-user server is allowed to view user-authored pages served by their server.
To protect all users from each other, JupyterHub administrators must ensure that:
* A user does not have permission to modify their single-user server:
- A user may not install new packages in the Python environment that runs their server.
- If the PATH is used to resolve the single-user executable (instead of an absolute path), a user may not create new files in any PATH directory that precedes the directory containing jupyterhub-singleuser.
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for their single-user server.
* A user may not modify the configuration of the notebook server (the ~/.jupyter or JUPYTER_CONFIG_DIR directory).
If any additional services are run on the same domain as the Hub, the services must never display user-authored HTML that is neither sanitized nor sandboxed (e.g. IFramed) to any user that lacks authentication as the author of a file.
## Mitigations
There are two main configuration options provided by JupyterHub to mitigate these issues:
### Subdomains
JupyterHub 0.5 adds the ability to run single-user servers on their own subdomains,
which means the cross-origin protections between servers has the desired effect,
and user servers and the Hub are protected from each other.
A user's server will be at `username.jupyter.mydomain.com`, etc.
This requires all user subdomains to point to the same address,
which is most easily accomplished with wildcard DNS.
Since this spreads the service across multiple domains, you will need wildcard SSL, as well.
Unfortunately, for many institutional domains, wildcard DNS and SSL are not available,
but if you do plan to serve untrusted users, enabling subdomains is highly encouraged,
as it resolves all of the cross-site issues.
### Disabling user config
If subdomains are not available or not desirable,
0.5 also adds an option `Spawner.disable_use_config`,
which you can set to prevent the user-owned configuration files from being loaded.
This leaves only package installation and PATHs as things the admin must enforce.
For most Spawners, PATH is not something users an influence,
but care should be taken to ensure that the Spawn does *not* evaluate shell configuration files prior to launching the server.
Package isolation is most easily handled by running the single-user server in a virtualenv with disabled system-site-packages.
## Extra notes
It is important to note that the control over the environment only affects the single-user server,
and not the environment(s) in which the user's kernel(s) may run.
Installing additional packages in the kernel environment does not pose additional risk to the web application's security.
[configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy

View File

@@ -1,4 +1,4 @@
FROM jupyter/jupyterhub
FROM jupyter/jupyterhub-onbuild
MAINTAINER Jupyter Project <jupyter@googlegroups.com>

View File

@@ -1,5 +1,5 @@
"""
Example JuptyerHub config allowing users to specify environment variables and notebook-server args
Example JupyterHub config allowing users to specify environment variables and notebook-server args
"""
import shlex

View File

@@ -6,7 +6,7 @@
import json
from urllib.parse import quote
from tornado import web
from tornado import web, gen
from .. import orm
from ..utils import token_authenticated
from .base import APIHandler
@@ -20,13 +20,25 @@ class TokenAPIHandler(APIHandler):
raise web.HTTPError(404)
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
@gen.coroutine
def post(self):
if self.authenticator is not None:
data = self.get_json_body()
username = yield self.authenticator.authenticate(self, data)
if username is None:
raise web.HTTPError(403)
user = self.find_user(username)
api_token = user.new_api_token()
self.write(json.dumps({"Authentication":api_token}))
else:
raise web.HTTPError(404)
class CookieAPIHandler(APIHandler):
@token_authenticated
def get(self, cookie_name, cookie_value=None):
cookie_name = quote(cookie_name, safe='')
if cookie_value is None:
self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
self.log.warning("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
cookie_value = self.request.body
else:
cookie_value = cookie_value.encode('utf8')
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
default_handlers = [
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
(r"/api/authorizations/token", TokenAPIHandler),
]

View File

@@ -26,25 +26,29 @@ class APIHandler(BaseHandler):
# If no header is provided, assume it comes from a script/curl.
# We are only concerned with cross-site browser stuff here.
if not host:
self.log.warn("Blocking API request with no host")
self.log.warning("Blocking API request with no host")
return False
if not referer:
self.log.warn("Blocking API request with no referer")
self.log.warning("Blocking API request with no referer")
return False
host_path = url_path_join(host, self.hub.server.base_url)
referer_path = referer.split('://', 1)[-1]
if not (referer_path + '/').startswith(host_path):
self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s",
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",
referer, host_path)
return False
return True
def get_current_user_cookie(self):
"""Override get_user_cookie to check Referer header"""
if not self.check_referer():
cookie_user = super().get_current_user_cookie()
# check referer only if there is a cookie user,
# avoiding misleading "Blocking Cross Origin" messages
# when there's no cookie set anyway.
if cookie_user and not self.check_referer():
return None
return super().get_current_user_cookie()
return cookie_user
def get_json_body(self):
"""Return the body of the request as JSON data."""

View File

@@ -41,7 +41,7 @@ class UserListAPIHandler(APIHandler):
continue
user = self.find_user(name)
if user is not None:
self.log.warn("User %s already exists" % name)
self.log.warning("User %s already exists" % name)
else:
to_create.append(name)
@@ -195,7 +195,7 @@ class UserAdminAccessAPIHandler(APIHandler):
@admin_only
def post(self, name):
current = self.get_current_user()
self.log.warn("Admin user %s has requested access to %s's server",
self.log.warning("Admin user %s has requested access to %s's server",
current.name, name,
)
if not self.settings.get('admin_access', False):

View File

@@ -12,6 +12,7 @@ import signal
import socket
import sys
import threading
import statsd
from datetime import datetime
from distutils.version import LooseVersion as V
from getpass import getuser
@@ -36,6 +37,7 @@ from tornado import gen, web
from traitlets import (
Unicode, Integer, Dict, TraitError, List, Bool, Any,
Type, Set, Instance, Bytes, Float,
observe, default,
)
from traitlets.config import Application, catch_config_error
@@ -58,6 +60,10 @@ from .utils import (
from .auth import Authenticator, PAMAuthenticator
from .spawner import Spawner, LocalProcessSpawner
# For faking stats
from .emptyclass import EmptyClass
common_aliases = {
'log-level': 'Application.log_level',
'f': 'JupyterHub.config_file',
@@ -175,66 +181,66 @@ class JupyterHub(Application):
PAMAuthenticator,
])
config_file = Unicode('jupyterhub_config.py', config=True,
config_file = Unicode('jupyterhub_config.py',
help="The config file to load",
)
generate_config = Bool(False, config=True,
).tag(config=True)
generate_config = Bool(False,
help="Generate default config file",
)
answer_yes = Bool(False, config=True,
).tag(config=True)
answer_yes = Bool(False,
help="Answer yes to any questions (e.g. confirm overwrite)"
)
pid_file = Unicode('', config=True,
).tag(config=True)
pid_file = Unicode('',
help="""File to write PID
Useful for daemonizing jupyterhub.
"""
)
cookie_max_age_days = Float(14, config=True,
).tag(config=True)
cookie_max_age_days = Float(14,
help="""Number of days for a login cookie to be valid.
Default is two weeks.
"""
)
last_activity_interval = Integer(300, config=True,
).tag(config=True)
last_activity_interval = Integer(300,
help="Interval (in seconds) at which to update last-activity timestamps."
)
proxy_check_interval = Integer(30, config=True,
).tag(config=True)
proxy_check_interval = Integer(30,
help="Interval (in seconds) at which to check if the proxy is running."
)
).tag(config=True)
data_files_path = Unicode(DATA_FILES_PATH, config=True,
data_files_path = Unicode(DATA_FILES_PATH,
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
)
).tag(config=True)
template_paths = List(
config=True,
help="Paths to search for jinja templates.",
)
).tag(config=True)
@default('template_paths')
def _template_paths_default(self):
return [os.path.join(self.data_files_path, 'templates')]
confirm_no_ssl = Bool(False, config=True,
confirm_no_ssl = Bool(False,
help="""Confirm that JupyterHub should be run without SSL.
This is **NOT RECOMMENDED** unless SSL termination is being handled by another layer.
"""
)
ssl_key = Unicode('', config=True,
).tag(config=True)
ssl_key = Unicode('',
help="""Path to SSL key file for the public facing interface of the proxy
Use with ssl_cert
"""
)
ssl_cert = Unicode('', config=True,
).tag(config=True)
ssl_cert = Unicode('',
help="""Path to SSL certificate file for the public facing interface of the proxy
Use with ssl_key
"""
)
ip = Unicode('', config=True,
).tag(config=True)
ip = Unicode('',
help="The public facing ip of the whole application (the proxy)"
)
).tag(config=True)
subdomain_host = Unicode('', config=True,
subdomain_host = Unicode('',
help="""Run single-user servers on subdomains of this host.
This should be the full https://hub.domain.tld[:port]
@@ -246,46 +252,53 @@ class JupyterHub(Application):
In general, this is most easily achieved with wildcard DNS.
When using SSL (i.e. always) this also requires a wildcard SSL certificate.
""")
"""
).tag(config=True)
def _subdomain_host_changed(self, name, old, new):
if new and '://' not in new:
# host should include '://'
# if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new
port = Integer(8000, config=True,
port = Integer(8000,
help="The public facing port of the proxy"
)
base_url = URLPrefix('/', config=True,
).tag(config=True)
base_url = URLPrefix('/',
help="The base URL of the entire application"
)
logo_file = Unicode('', config=True,
).tag(config=True)
logo_file = Unicode('',
help="Specify path to a logo image to override the Jupyter logo in the banner."
)
).tag(config=True)
@default('logo_file')
def _logo_file_default(self):
return os.path.join(self.data_files_path, 'static', 'images', 'jupyter.png')
jinja_environment_options = Dict(config=True,
jinja_environment_options = Dict(
help="Supply extra arguments that will be passed to Jinja environment."
)
).tag(config=True)
proxy_cmd = Command('configurable-http-proxy', config=True,
proxy_cmd = Command('configurable-http-proxy',
help="""The command to start the http proxy.
Only override if configurable-http-proxy is not on your PATH
"""
)
debug_proxy = Bool(False, config=True, help="show debug output in configurable-http-proxy")
proxy_auth_token = Unicode(config=True,
).tag(config=True)
debug_proxy = Bool(False,
help="show debug output in configurable-http-proxy"
).tag(config=True)
proxy_auth_token = Unicode(
help="""The Proxy Auth token.
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
"""
)
).tag(config=True)
@default('proxy_auth_token')
def _proxy_auth_token_default(self):
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None)
if not token:
self.log.warn('\n'.join([
self.log.warning('\n'.join([
"",
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.",
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHub.proxy_auth_token config to avoid this message.",
@@ -294,46 +307,60 @@ class JupyterHub(Application):
token = orm.new_token()
return token
proxy_api_ip = Unicode('127.0.0.1', config=True,
proxy_api_ip = Unicode('127.0.0.1',
help="The ip for the proxy API handlers"
)
proxy_api_port = Integer(config=True,
).tag(config=True)
proxy_api_port = Integer(
help="The port for the proxy API handlers"
)
).tag(config=True)
@default('proxy_api_port')
def _proxy_api_port_default(self):
return self.port + 1
hub_port = Integer(8081, config=True,
hub_port = Integer(8081,
help="The port for this process"
)
hub_ip = Unicode('127.0.0.1', config=True,
).tag(config=True)
hub_ip = Unicode('127.0.0.1',
help="The ip for this process"
)
hub_prefix = URLPrefix('/hub/', config=True,
).tag(config=True)
hub_prefix = URLPrefix('/hub/',
help="The prefix for the hub server. Must not be '/'"
)
).tag(config=True)
@default('hub_prefix')
def _hub_prefix_default(self):
return url_path_join(self.base_url, '/hub/')
@observe('hub_prefix')
def _hub_prefix_changed(self, name, old, new):
if new == '/':
raise TraitError("'/' is not a valid hub prefix")
if not new.startswith(self.base_url):
self.hub_prefix = url_path_join(self.base_url, new)
cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET',
cookie_secret = Bytes(
help="""The cookie secret to use to encrypt cookies.
Loaded from the JPY_COOKIE_SECRET env variable by default.
"""
).tag(
config=True,
env='JPY_COOKIE_SECRET',
)
cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True,
cookie_secret_file = Unicode('jupyterhub_cookie_secret',
help="""File in which to store the cookie secret."""
)
).tag(config=True)
api_tokens = Dict(Unicode(),
help="""Dict of token:username to be loaded into the database.
Allows ahead-of-time generation of API tokens for use by services.
"""
).tag(config=True)
authenticator_class = Type(PAMAuthenticator, Authenticator,
config=True,
help="""Class for authenticating users.
This should be a class with the following form:
@@ -346,61 +373,69 @@ class JupyterHub(Application):
where `handler` is the calling web.RequestHandler,
and `data` is the POST form data from the login page.
"""
)
).tag(config=True)
authenticator = Instance(Authenticator)
@default('authenticator')
def _authenticator_default(self):
return self.authenticator_class(parent=self, db=self.db)
# class for spawning single-user servers
spawner_class = Type(LocalProcessSpawner, Spawner,
config=True,
help="""The class to use for spawning single-user servers.
Should be a subclass of Spawner.
"""
)
).tag(config=True)
db_url = Unicode('sqlite:///jupyterhub.sqlite', config=True,
db_url = Unicode('sqlite:///jupyterhub.sqlite',
help="url for the database. e.g. `sqlite:///jupyterhub.sqlite`"
)
def _db_url_changed(self, name, old, new):
).tag(config=True)
@observe('db_url')
def _db_url_changed(self, change):
new = change['new']
if '://' not in new:
# assume sqlite, if given as a plain filename
self.db_url = 'sqlite:///%s' % new
db_kwargs = Dict(config=True,
db_kwargs = Dict(
help="""Include any kwargs to pass to the database connection.
See sqlalchemy.create_engine for details.
"""
)
).tag(config=True)
reset_db = Bool(False, config=True,
reset_db = Bool(False,
help="Purge and reset the database."
)
debug_db = Bool(False, config=True,
).tag(config=True)
debug_db = Bool(False,
help="log all database transactions. This has A LOT of output"
)
).tag(config=True)
session_factory = Any()
users = Instance(UserDict)
@default('users')
def _users_default(self):
assert self.tornado_settings
return UserDict(db_factory=lambda : self.db, settings=self.tornado_settings)
admin_access = Bool(False, config=True,
admin_access = Bool(False,
help="""Grant admin users permission to access single-user servers.
Users should be properly informed if this is enabled.
"""
)
admin_users = Set(config=True,
).tag(config=True)
admin_users = Set(
help="""DEPRECATED, use Authenticator.admin_users instead."""
)
).tag(config=True)
tornado_settings = Dict(config=True)
tornado_settings = Dict(
help="Extra settings overrides to pass to the tornado application."
).tag(config=True)
cleanup_servers = Bool(True, config=True,
cleanup_servers = Bool(True,
help="""Whether to shutdown single-user servers when the Hub shuts down.
Disable if you want to be able to teardown the Hub while leaving the single-user servers running.
@@ -410,9 +445,9 @@ class JupyterHub(Application):
The Hub should be able to resume from database state.
"""
)
).tag(config=True)
cleanup_proxy = Bool(True, config=True,
cleanup_proxy = Bool(True,
help="""Whether to shutdown the proxy when the Hub shuts down.
Disable if you want to be able to teardown the Hub while leaving the proxy running.
@@ -424,7 +459,21 @@ class JupyterHub(Application):
The Hub should be able to resume from database state.
"""
)
).tag(config=True)
statsd_host = Unicode(
help="Host to send statds metrics to"
).tag(config=True)
statsd_port = Integer(
8125,
help="Port on which to send statsd metrics about the hub"
).tag(config=True)
statsd_prefix = Unicode(
'jupyterhub',
help="Prefix to use for all metrics sent by jupyterhub to statsd"
).tag(config=True)
handlers = List()
@@ -433,27 +482,47 @@ class JupyterHub(Application):
proxy_process = None
io_loop = None
@default('log_level')
def _log_level_default(self):
return logging.INFO
@default('log_datefmt')
def _log_datefmt_default(self):
"""Exclude date from default date format"""
return "%Y-%m-%d %H:%M:%S"
@default('log_format')
def _log_format_default(self):
"""override default log format to include time"""
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
extra_log_file = Unicode(
"",
config=True,
help="Set a logging.FileHandler on this file."
)
help="""Send JupyterHub's logs to this file.
This will *only* include the logs of the Hub itself,
not the logs of the proxy or any single-user servers.
"""
).tag(config=True)
extra_log_handlers = List(
Instance(logging.Handler),
config=True,
help="Extra log handlers to set on JupyterHub logger",
)
).tag(config=True)
@property
def statsd(self):
if hasattr(self, '_statsd'):
return self._statsd
if self.statsd_host:
self._statsd = statsd.StatsClient(
self.statsd_host,
self.statsd_port,
self.statsd_prefix
)
return self._statsd
else:
# return an empty mock object!
self._statsd = EmptyClass()
return self._statsd
def init_logging(self):
# This prevents double log messages because tornado use a root logger that
@@ -538,30 +607,34 @@ class JupyterHub(Application):
def init_secrets(self):
trait_name = 'cookie_secret'
trait = self.traits()[trait_name]
env_name = trait.get_metadata('env')
env_name = trait.metadata.get('env')
secret_file = os.path.abspath(
os.path.expanduser(self.cookie_secret_file)
)
secret = self.cookie_secret
secret_from = 'config'
# load priority: 1. config, 2. env, 3. file
if not secret and os.environ.get(env_name):
secret_env = os.environ.get(env_name)
if not secret and secret_env:
secret_from = 'env'
self.log.info("Loading %s from env[%s]", trait_name, env_name)
secret = binascii.a2b_hex(os.environ[env_name])
secret = binascii.a2b_hex(secret_env)
if not secret and os.path.exists(secret_file):
secret_from = 'file'
perm = os.stat(secret_file).st_mode
if perm & 0o077:
self.log.error("Bad permissions on %s", secret_file)
else:
self.log.info("Loading %s from %s", trait_name, secret_file)
self.log.info("Loading %s from %s", trait_name, secret_file)
try:
perm = os.stat(secret_file).st_mode
if perm & 0o07:
raise ValueError("cookie_secret_file can be read or written by anybody")
with open(secret_file) as f:
b64_secret = f.read()
try:
secret = binascii.a2b_base64(b64_secret)
except Exception as e:
self.log.error("%s does not contain b64 key: %s", secret_file, e)
secret = binascii.a2b_base64(b64_secret)
except Exception as e:
self.log.error(
"Refusing to run JupyterHub with invalid cookie_secret_file. "
"%s error was: %s",
secret_file, e)
self.exit(1)
if not secret:
secret_from = 'new'
self.log.debug("Generating new %s", trait_name)
@@ -576,7 +649,7 @@ class JupyterHub(Application):
try:
os.chmod(secret_file, 0o600)
except OSError:
self.log.warn("Failed to set permissions on %s", secret_file)
self.log.warning("Failed to set permissions on %s", secret_file)
# store the loaded trait value
self.cookie_secret = secret
@@ -668,7 +741,7 @@ class JupyterHub(Application):
db = self.db
if self.admin_users and not self.authenticator.admin_users:
self.log.warn(
self.log.warning(
"\nJupyterHub.admin_users is deprecated."
"\nUse Authenticator.admin_users instead."
)
@@ -677,6 +750,7 @@ class JupyterHub(Application):
self.authenticator.normalize_username(name)
for name in self.authenticator.admin_users
]
self.authenticator.admin_users = set(admin_users) # force normalization
for username in admin_users:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
@@ -704,6 +778,7 @@ class JupyterHub(Application):
self.authenticator.normalize_username(name)
for name in self.authenticator.whitelist
]
self.authenticator.whitelist = set(whitelist) # force normalization
for username in whitelist:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
@@ -719,23 +794,51 @@ class JupyterHub(Application):
new_users.append(user)
db.add(user)
if whitelist:
# fill the whitelist with any users loaded from the db,
# so we are consistent in both directions.
# This lets whitelist be used to set up initial list,
# but changes to the whitelist can occur in the database,
# and persist across sessions.
for user in db.query(orm.User):
self.authenticator.whitelist.add(user.name)
db.commit()
# Notify authenticator of all users.
# This ensures Auth whitelist is up-to-date with the database.
# This lets whitelist be used to set up initial list,
# but changes to the whitelist can occur in the database,
# and persist across sessions.
for user in db.query(orm.User):
yield gen.maybe_future(self.authenticator.add_user(user))
db.commit() # can add_user touch the db?
# The whitelist set and the users in the db are now the same.
# From this point on, any user changes should be done simultaneously
# to the whitelist set and user db, unless the whitelist is empty (all users allowed).
db.commit()
for user in new_users:
yield gen.maybe_future(self.authenticator.add_user(user))
def init_api_tokens(self):
"""Load predefined API tokens (for services) into database"""
db = self.db
for token, username in self.api_tokens.items():
username = self.authenticator.normalize_username(username)
if not self.authenticator.check_whitelist(username):
raise ValueError("Token username %r is not in whitelist" % username)
if not self.authenticator.validate_username(username):
raise ValueError("Token username %r is not valid" % username)
orm_token = orm.APIToken.find(db, token)
if orm_token is None:
user = orm.User.find(db, username)
user_created = False
if user is None:
user_created = True
self.log.debug("Adding user %r to database", username)
user = orm.User(name=username)
db.add(user)
db.commit()
self.log.info("Adding API token for %s", username)
try:
user.new_api_token(token)
except Exception:
if user_created:
# don't allow bad tokens to create users
db.delete(user)
db.commit()
raise
else:
self.log.debug("Not duplicating token %s", orm_token)
db.commit()
@gen.coroutine
@@ -754,7 +857,7 @@ class JupyterHub(Application):
@gen.coroutine
def user_stopped(user):
status = yield user.spawner.poll()
self.log.warn("User %s server stopped with exit code: %s",
self.log.warning("User %s server stopped with exit code: %s",
user.name, status,
)
yield self.proxy.delete_user(user)
@@ -778,7 +881,7 @@ class JupyterHub(Application):
# user not running. This is expected if server is None,
# but indicates the user's server died while the Hub wasn't running
# if user.server is defined.
log = self.log.warn if user.server else self.log.debug
log = self.log.warning if user.server else self.log.debug
log("%s not running.", user.name)
user.server = None
@@ -845,6 +948,12 @@ class JupyterHub(Application):
cmd.extend(['--ssl-key', self.ssl_key])
if self.ssl_cert:
cmd.extend(['--ssl-cert', self.ssl_cert])
if self.statsd_host:
cmd.extend([
'--statsd-host', self.statsd_host,
'--statsd-port', str(self.statsd_port),
'--statsd-prefix', self.statsd_prefix + '.chp'
])
# Require SSL to be used or `--no-ssl` to confirm no SSL on
if ' --ssl' not in ' '.join(cmd):
if self.confirm_no_ssl:
@@ -949,6 +1058,7 @@ class JupyterHub(Application):
version_hash=version_hash,
subdomain_host=subdomain_host,
domain=domain,
statsd=self.statsd,
)
# allow configured settings to have priority
settings.update(self.tornado_settings)
@@ -976,7 +1086,7 @@ class JupyterHub(Application):
self.load_config_file(self.config_file)
self.init_logging()
if 'JupyterHubApp' in self.config:
self.log.warn("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
self.log.warning("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
'\n'.join('JupyterHubApp.{key} = {value!r}'.format(key=key, value=value)
for key, value in self.config.JupyterHubApp.items()
)
@@ -991,6 +1101,7 @@ class JupyterHub(Application):
self.init_hub()
self.init_proxy()
yield self.init_users()
self.init_api_tokens()
self.init_tornado_settings()
yield self.init_spawners()
self.init_handlers()
@@ -1070,19 +1181,27 @@ class JupyterHub(Application):
def update_last_activity(self):
"""Update User.last_activity timestamps from the proxy"""
routes = yield self.proxy.get_routes()
users_count = 0
active_users_count = 0
for prefix, route in routes.items():
if 'user' not in route:
# not a user route, ignore it
continue
user = orm.User.find(self.db, route['user'])
if user is None:
self.log.warn("Found no user for route: %s", route)
self.log.warning("Found no user for route: %s", route)
continue
try:
dt = datetime.strptime(route['last_activity'], ISO8601_ms)
except Exception:
dt = datetime.strptime(route['last_activity'], ISO8601_s)
user.last_activity = max(user.last_activity, dt)
# FIXME: Make this configurable duration. 30 minutes for now!
if (datetime.now() - user.last_activity).total_seconds() < 30 * 60:
active_users_count += 1
users_count += 1
self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count)
self.db.commit()
yield self.proxy.check_routes(self.users, routes)

View File

@@ -15,7 +15,7 @@ from tornado import gen
import pamela
from traitlets.config import LoggingConfigurable
from traitlets import Bool, Set, Unicode, Dict, Any
from traitlets import Bool, Set, Unicode, Dict, Any, default, observe
from .handlers.login import LoginHandler
from .utils import url_path_join
@@ -29,19 +29,19 @@ class Authenticator(LoggingConfigurable):
"""
db = Any()
admin_users = Set(config=True,
admin_users = Set(
help="""set of usernames of admin users
If unspecified, only the user that launches the server will be admin.
"""
)
whitelist = Set(config=True,
).tag(config=True)
whitelist = Set(
help="""Username whitelist.
Use this to restrict which users can login.
If empty, allow any user to attempt login.
"""
)
).tag(config=True)
custom_html = Unicode('',
help="""HTML login form for custom handlers.
Override in form-based custom authenticators
@@ -55,16 +55,17 @@ class Authenticator(LoggingConfigurable):
"""
)
username_pattern = Unicode(config=True,
username_pattern = Unicode(
help="""Regular expression pattern for validating usernames.
If not defined: allow any username.
"""
)
def _username_pattern_changed(self, name, old, new):
if not new:
).tag(config=True)
@observe('username_pattern')
def _username_pattern_changed(self, change):
if not change['new']:
self.username_regex = None
self.username_regex = re.compile(new)
self.username_regex = re.compile(change['new'])
username_regex = Any()
@@ -77,14 +78,14 @@ class Authenticator(LoggingConfigurable):
return True
return bool(self.username_regex.match(username))
username_map = Dict(config=True,
username_map = Dict(
help="""Dictionary mapping authenticator usernames to JupyterHub users.
Can be used to map OAuth service names to local users, for instance.
Used in normalize_username.
"""
)
).tag(config=True)
def normalize_username(self, username):
"""Normalize a username.
@@ -246,12 +247,12 @@ class LocalAuthenticator(Authenticator):
Checks for local users, and can attempt to create them if they exist.
"""
create_system_users = Bool(False, config=True,
create_system_users = Bool(False,
help="""If a user is added that doesn't exist on the system,
should I try to create the system user?
"""
)
add_user_cmd = Command(config=True,
).tag(config=True)
add_user_cmd = Command(
help="""The command to use for creating users as a list of strings.
For each element in the list, the string USERNAME will be replaced with
@@ -271,7 +272,9 @@ class LocalAuthenticator(Authenticator):
when the user 'river' is created.
"""
)
).tag(config=True)
@default('add_user_cmd')
def _add_user_cmd_default(self):
if sys.platform == 'darwin':
raise ValueError("I don't know how to create users on OS X")
@@ -283,13 +286,12 @@ class LocalAuthenticator(Authenticator):
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
group_whitelist = Set(
config=True,
help="Automatically whitelist anyone in this group.",
)
def _group_whitelist_changed(self, name, old, new):
).tag(config=True)
@observe('group_whitelist')
def _group_whitelist_changed(self, change):
if self.whitelist:
self.log.warn(
self.log.warning(
"Ignoring username whitelist because group whitelist supplied!"
)
@@ -351,12 +353,24 @@ class LocalAuthenticator(Authenticator):
class PAMAuthenticator(LocalAuthenticator):
"""Authenticate local Linux/UNIX users with PAM"""
encoding = Unicode('utf8', config=True,
encoding = Unicode('utf8',
help="""The encoding to use for PAM"""
)
service = Unicode('login', config=True,
).tag(config=True)
service = Unicode('login',
help="""The PAM service to use for authentication."""
)
).tag(config=True)
open_sessions = Bool(True,
help="""Whether to open PAM sessions when spawners are started.
This may trigger things like mounting shared filsystems,
loading credentials, etc. depending on system configuration,
but it does not always work.
It can be disabled with::
c.PAMAuthenticator.open_sessions = False
"""
).tag(config=True)
@gen.coroutine
def authenticate(self, handler, data):
@@ -369,23 +383,31 @@ class PAMAuthenticator(LocalAuthenticator):
pamela.authenticate(username, data['password'], service=self.service)
except pamela.PAMError as e:
if handler is not None:
self.log.warn("PAM Authentication failed (@%s): %s", handler.request.remote_ip, e)
self.log.warning("PAM Authentication failed (%s@%s): %s", username, handler.request.remote_ip, e)
else:
self.log.warn("PAM Authentication failed: %s", e)
self.log.warning("PAM Authentication failed: %s", e)
else:
return username
def pre_spawn_start(self, user, spawner):
"""Open PAM session for user"""
if not self.open_sessions:
return
try:
pamela.open_session(user.name, service=self.service)
except pamela.PAMError as e:
self.log.warn("Failed to open PAM session for %s: %s", user.name, e)
self.log.warning("Failed to open PAM session for %s: %s", user.name, e)
self.log.warning("Disabling PAM sessions from now on.")
self.open_sessions = False
def post_spawn_stop(self, user, spawner):
"""Close PAM session for user"""
if not self.open_sessions:
return
try:
pamela.close_session(user.name, service=self.service)
except pamela.PAMError as e:
self.log.warn("Failed to close PAM session for %s: %s", user.name, e)
self.log.warning("Failed to close PAM session for %s: %s", user.name, e)
self.log.warning("Disabling PAM sessions from now on.")
self.open_sessions = False

13
jupyterhub/emptyclass.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Simple empty class that returns itself for all functions called on it.
This allows us to call any method of any name on this, and it'll return another
instance of itself that'll allow any method to be called on it.
Primarily used to mock out the statsd client when statsd is not being used
"""
class EmptyClass:
def empty_function(self, *args, **kwargs):
return self
def __getattr__(self, attr):
return self.empty_function

View File

@@ -75,6 +75,10 @@ class BaseHandler(RequestHandler):
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)
@@ -154,14 +158,14 @@ class BaseHandler(RequestHandler):
if cookie_id is None:
if self.get_cookie(cookie_name):
self.log.warn("Invalid or expired cookie token")
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.warn("Invalid cookie token")
self.log.warning("Invalid cookie token")
# have cookie, but it's not valid. Clear it and start over.
clear()
return user
@@ -200,6 +204,7 @@ class BaseHandler(RequestHandler):
self.db.add(u)
self.db.commit()
user = self._user_from_orm(u)
self.authenticator.add_user(user)
return user
def clear_login_cookie(self, name=None):
@@ -299,6 +304,7 @@ class BaseHandler(RequestHandler):
return
toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
self.statsd.timing('spawner.success', (toc - tic) * 1000)
yield self.proxy.add_user(user)
user.spawner.add_poll_callback(self.user_stopped, user)
@@ -308,7 +314,7 @@ class BaseHandler(RequestHandler):
if user.spawn_pending:
# still in Spawner.start, which is taking a long time
# we shouldn't poll while spawn_pending is True
self.log.warn("User %s server is slow to start", user.name)
self.log.warning("User %s server is slow to start", user.name)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
@@ -318,10 +324,12 @@ class BaseHandler(RequestHandler):
if status is None:
# hit timeout, 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.warn("User %s server is slow to become responsive", user.name)
self.log.warning("User %s server is slow to become responsive", user.name)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
toc = IOLoop.current().time()
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
else:
yield finish_user_spawn()
@@ -332,7 +340,7 @@ class BaseHandler(RequestHandler):
status = yield user.spawner.poll()
if status is None:
status = 'unknown'
self.log.warn("User %s server stopped, with exit code: %s",
self.log.warning("User %s server stopped, with exit code: %s",
user.name, status,
)
yield self.proxy.delete_user(user)
@@ -363,7 +371,7 @@ class BaseHandler(RequestHandler):
except gen.TimeoutError:
if user.stop_pending:
# hit timeout, but stop is still pending
self.log.warn("User %s server is slow to stop", user.name)
self.log.warning("User %s server is slow to stop", user.name)
# schedule finish for when the server finishes stopping
IOLoop.current().add_future(f, finish_stop)
else:
@@ -470,6 +478,7 @@ class UserSpawnHandler(BaseHandler):
if current_user.spawner:
if current_user.spawn_pending:
# spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1)
html = self.render_template("spawn_pending.html", user=current_user)
self.finish(html)
return
@@ -489,13 +498,15 @@ class UserSpawnHandler(BaseHandler):
if self.subdomain_host:
target = current_user.host + target
self.redirect(target)
self.statsd.incr('redirects.user_after_login')
elif current_user:
# logged in as a different user, redirect
target = url_path_join(self.base_url, 'user', current_user.name,
user_path or '')
self.statsd.incr('redirects.user_to_user', 1)
target = url_path_join(current_user.url, user_path or '')
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'],
@@ -508,8 +519,12 @@ class CSPReportHandler(BaseHandler):
@web.authenticated
def post(self):
'''Log a content security policy violation report'''
self.log.warn("Content security violation: %s",
self.request.body.decode('utf8', 'replace'))
self.log.warning(
"Content security violation: %s",
self.request.body.decode('utf8', 'replace')
)
# Report it to statsd as well
self.statsd.incr('csp_report')
default_handlers = [
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),

View File

@@ -20,6 +20,7 @@ class LogoutHandler(BaseHandler):
self.clear_login_cookie(name)
user.other_user_cookies = set([])
self.redirect(self.hub.server.base_url, permanent=False)
self.statsd.incr('logout')
class LoginHandler(BaseHandler):
@@ -35,6 +36,7 @@ class LoginHandler(BaseHandler):
)
def get(self):
self.statsd.incr('login.request')
next_url = self.get_argument('next', '')
if not next_url.startswith('/'):
# disallow non-absolute next URLs (e.g. full URLs)
@@ -43,7 +45,7 @@ class LoginHandler(BaseHandler):
if user:
if not next_url:
if user.running:
next_url = user.server.base_url
next_url = user.url
else:
next_url = self.hub.server.base_url
# set new login cookie
@@ -61,8 +63,13 @@ class LoginHandler(BaseHandler):
for arg in self.request.arguments:
data[arg] = self.get_argument(arg)
auth_timer = self.statsd.timer('login.authenticate').start()
username = yield self.authenticate(data)
auth_timer.stop(send=False)
if username:
self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms)
user = self.user_from_username(username)
already_running = False
if user.spawner:
@@ -78,7 +85,9 @@ class LoginHandler(BaseHandler):
self.redirect(next_url)
self.log.info("User logged in: %s", username)
else:
self.log.debug("Failed login for %s", username)
self.statsd.incr('login.failure')
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
html = self._render(
login_error='Invalid username or password',
username=username,

View File

@@ -8,7 +8,7 @@ from tornado import web, gen
from .. import orm
from ..utils import admin_only, url_path_join
from .base import BaseHandler
from .login import LoginHandler
from urllib.parse import quote
class RootHandler(BaseHandler):
@@ -27,6 +27,7 @@ class RootHandler(BaseHandler):
if user.running:
url = user.url
self.log.debug("User is running: %s", url)
self.set_login_cookie(user) # set cookie
else:
url = url_path_join(self.hub.server.base_url, 'home')
self.log.debug("User is not running: %s", url)
@@ -75,8 +76,7 @@ class SpawnHandler(BaseHandler):
self.finish(self._render_form())
else:
# not running, no form. Trigger spawn.
url = url_path_join(self.base_url, 'user', user.name)
self.redirect(url)
self.redirect(user.url)
@web.authenticated
@gen.coroutine
@@ -124,10 +124,10 @@ class AdminHandler(BaseHandler):
orders = self.get_arguments('order')
for bad in set(sorts).difference(available):
self.log.warn("ignoring invalid sort: %r", bad)
self.log.warning("ignoring invalid sort: %r", bad)
sorts.remove(bad)
for bad in set(orders).difference({'asc', 'desc'}):
self.log.warn("ignoring invalid order: %r", bad)
self.log.warning("ignoring invalid order: %r", bad)
orders.remove(bad)
# add default sort as secondary

View File

@@ -303,11 +303,21 @@ class User(Base):
name=self.name,
)
def new_api_token(self):
"""Create a new API token"""
def new_api_token(self, token=None):
"""Create a new API token
If `token` is given, load that token.
"""
assert self.id is not None
db = inspect(self).session
token = new_token()
if token is None:
token = new_token()
else:
if len(token) < 8:
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
found = APIToken.find(db, token)
if found:
raise ValueError("Collision on token: %s..." % token[:4])
orm_token = APIToken(user_id=self.id)
orm_token.token = token
db.add(orm_token)
@@ -378,6 +388,8 @@ def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
"""Create a new session at url"""
if url.startswith('sqlite'):
kwargs.setdefault('connect_args', {'check_same_thread': False})
elif url.startswith('mysql'):
kwargs.setdefault('pool_recycle', 60)
if url.endswith(':memory:'):
# If we're using an in-memory database, ensure that only one connection

View File

@@ -10,6 +10,7 @@ import pwd
import signal
import sys
import grp
import warnings
from subprocess import Popen
from tempfile import mkdtemp
@@ -41,39 +42,38 @@ class Spawner(LoggingConfigurable):
hub = Any()
authenticator = Any()
api_token = Unicode()
ip = Unicode('127.0.0.1', config=True,
ip = Unicode('127.0.0.1',
help="The IP address (or hostname) the single-user server should listen on"
)
start_timeout = Integer(60, config=True,
).tag(config=True)
start_timeout = Integer(60,
help="""Timeout (in seconds) before giving up on the spawner.
This is the timeout for start to return, not the timeout for the server to respond.
Callers of spawner.start will assume that startup has failed if it takes longer than this.
start should return when the server process is started and its location is known.
"""
)
).tag(config=True)
http_timeout = Integer(
30, config=True,
http_timeout = Integer(30,
help="""Timeout (in seconds) before giving up on a spawned HTTP server
Once a server has successfully been spawned, this is the amount of time
we wait before assuming that the server is unable to accept
connections.
"""
)
).tag(config=True)
poll_interval = Integer(30, config=True,
poll_interval = Integer(30,
help="""Interval (in seconds) on which to poll the spawner."""
)
).tag(config=True)
_callbacks = List()
_poll_callback = Any()
debug = Bool(False, config=True,
debug = Bool(False,
help="Enable debug-logging of the single-user server"
)
).tag(config=True)
options_form = Unicode("", config=True, help="""
options_form = Unicode("", help="""
An HTML form for options a user can specify on launching their server.
The surrounding `<form>` element and the submit button are already provided.
@@ -87,7 +87,7 @@ class Spawner(LoggingConfigurable):
<option value="A">The letter A</option>
<option value="B">The letter B</option>
</select>
""")
""").tag(config=True)
def options_from_form(self, form_data):
"""Interpret HTTP form data
@@ -113,34 +113,41 @@ class Spawner(LoggingConfigurable):
'VIRTUAL_ENV',
'LANG',
'LC_ALL',
], config=True,
],
help="Whitelist of environment variables for the subprocess to inherit"
)
env = Dict()
def _env_default(self):
env = {}
for key in self.env_keep:
if key in os.environ:
env[key] = os.environ[key]
env['JPY_API_TOKEN'] = self.api_token
return env
).tag(config=True)
env = Dict(help="""Deprecated: use Spawner.get_env or Spawner.environment
cmd = Command(['jupyterhub-singleuser'], config=True,
- extend Spawner.get_env for adding required env in Spawner subclasses
- Spawner.environment for config-specified env
""")
environment = Dict(
help="""Environment variables to load for the Spawner.
Value could be a string or a callable. If it is a callable, it will
be called with one parameter, which will be the instance of the spawner
in use. It should quickly (without doing much blocking operations) return
a string that will be used as the value for the environment variable.
"""
).tag(config=True)
cmd = Command(['jupyterhub-singleuser'],
help="""The command used for starting notebooks."""
)
args = List(Unicode, config=True,
).tag(config=True)
args = List(Unicode(),
help="""Extra arguments to be passed to the single-user server"""
)
).tag(config=True)
notebook_dir = Unicode('', config=True,
notebook_dir = Unicode('',
help="""The notebook directory for the single-user server
`~` will be expanded to the user's home directory
`%U` will be expanded to the user's username
"""
)
).tag(config=True)
default_url = Unicode('', config=True,
default_url = Unicode('',
help="""The default URL for the single-user server.
Can be used in conjunction with --notebook-dir=/ to enable
@@ -149,15 +156,15 @@ class Spawner(LoggingConfigurable):
`%U` will be expanded to the user's username
"""
)
).tag(config=True)
disable_user_config = Bool(False, config=True,
disable_user_config = Bool(False,
help="""Disable per-user configuration of single-user servers.
This prevents any config in users' $HOME directories
from having an effect on their server.
"""
)
).tag(config=True)
def __init__(self, **kwargs):
super(Spawner, self).__init__(**kwargs)
@@ -204,12 +211,34 @@ class Spawner(LoggingConfigurable):
self.api_token = ''
def get_env(self):
"""Return the environment we should use
"""Return the environment dict to use for the Spawner.
This applies things like `env_keep`, anything defined in `Spawner.environment`,
and adds the API token to the env.
Default returns a copy of self.env.
Use this to access the env in Spawner.start to allow extension in subclasses.
"""
return self.env.copy()
env = {}
if self.env:
warnings.warn("Spawner.env is deprecated, found %s" % self.env, DeprecationWarning)
env.update(self.env)
for key in self.env_keep:
if key in os.environ:
env[key] = os.environ[key]
# config overrides. If the value is a callable, it will be called with
# one parameter - the current spawner instance - and the return value
# will be assigned to the environment variable. This will be called at
# spawn time.
for key, value in self.environment.items():
if callable(value):
env[key] = value(self)
else:
env[key] = value
env['JPY_API_TOKEN'] = self.api_token
return env
def get_args(self):
"""Return the arguments to be passed after self.cmd"""
@@ -369,15 +398,15 @@ class LocalProcessSpawner(Spawner):
This is the default spawner for JupyterHub.
"""
INTERRUPT_TIMEOUT = Integer(10, config=True,
INTERRUPT_TIMEOUT = Integer(10,
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
)
TERM_TIMEOUT = Integer(5, config=True,
).tag(config=True)
TERM_TIMEOUT = Integer(5,
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
)
KILL_TIMEOUT = Integer(5, config=True,
).tag(config=True)
KILL_TIMEOUT = Integer(5,
help="Seconds to wait for process to halt after SIGKILL before giving up"
)
).tag(config=True)
proc = Instance(Popen, allow_none=True)
pid = Integer(0)
@@ -513,5 +542,5 @@ class LocalProcessSpawner(Spawner):
status = yield self.poll()
if status is None:
# it all failed, zombie process
self.log.warn("Process %i never died", self.pid)
self.log.warning("Process %i never died", self.pid)

View File

@@ -13,6 +13,8 @@ from tornado import gen
from tornado.concurrent import Future
from tornado.ioloop import IOLoop
from traitlets import default
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from .. import orm
@@ -44,7 +46,7 @@ class MockSpawner(LocalProcessSpawner):
def user_env(self, env):
return env
@default('cmd')
def _cmd_default(self):
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
@@ -66,6 +68,7 @@ class SlowSpawner(MockSpawner):
class NeverSpawner(MockSpawner):
"""A spawner that will never start"""
@default('start_timeout')
def _start_timeout_default(self):
return 1
@@ -90,6 +93,7 @@ class FormSpawner(MockSpawner):
class MockPAMAuthenticator(PAMAuthenticator):
@default('admin_users')
def _admin_users_default(self):
return {'admin'}
@@ -113,15 +117,19 @@ class MockHub(JupyterHub):
last_activity_interval = 2
@default('subdomain_host')
def _subdomain_host_default(self):
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
@default('ip')
def _ip_default(self):
return '127.0.0.1'
@default('authenticator_class')
def _authenticator_class_default(self):
return MockPAMAuthenticator
@default('spawner_class')
def _spawner_class_default(self):
return MockSpawner

View File

@@ -493,6 +493,27 @@ def test_token(app):
r = api_request(app, 'authorizations/token', 'notauthorized')
assert r.status_code == 404
def test_get_token(app):
name = 'user'
user = add_user(app.db, app=app, name=name)
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
'username': name,
'password': name,
}))
assert r.status_code == 200
data = r.content.decode("utf-8")
token = json.loads(data)
assert not token['Authentication'] is None
def test_bad_get_token(app):
name = 'user'
password = 'fake'
user = add_user(app.db, app=app, name=name)
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
'username': name,
'password': password,
}))
assert r.status_code == 403
def test_options(app):
r = api_request(app, 'users', method='options')

View File

@@ -1,11 +1,17 @@
"""Test the JupyterHub entry point"""
import binascii
import os
import re
import sys
from subprocess import check_output, Popen, PIPE
from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch
import pytest
from .mocking import MockHub
from .. import orm
def test_help_all():
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
@@ -48,3 +54,89 @@ def test_generate_config():
assert cfg_file in out
assert 'Spawner.cmd' in cfg_text
assert 'Authenticator.whitelist' in cfg_text
def test_init_tokens():
with TemporaryDirectory() as td:
db_file = os.path.join(td, 'jupyterhub.sqlite')
tokens = {
'super-secret-token': 'alyx',
'also-super-secret': 'gordon',
'boagasdfasdf': 'chell',
}
app = MockHub(db_file=db_file, api_tokens=tokens)
app.initialize([])
db = app.db
for token, username in tokens.items():
api_token = orm.APIToken.find(db, token)
assert api_token is not None
user = api_token.user
assert user.name == username
# simulate second startup, reloading same tokens:
app = MockHub(db_file=db_file, api_tokens=tokens)
app.initialize([])
db = app.db
for token, username in tokens.items():
api_token = orm.APIToken.find(db, token)
assert api_token is not None
user = api_token.user
assert user.name == username
# don't allow failed token insertion to create users:
tokens['short'] = 'gman'
app = MockHub(db_file=db_file, api_tokens=tokens)
# with pytest.raises(ValueError):
app.initialize([])
assert orm.User.find(app.db, 'gman') is None
def test_write_cookie_secret(tmpdir):
secret_path = str(tmpdir.join('cookie_secret'))
hub = MockHub(cookie_secret_file=secret_path)
hub.init_secrets()
assert os.path.exists(secret_path)
assert os.stat(secret_path).st_mode & 0o600
assert not os.stat(secret_path).st_mode & 0o177
def test_cookie_secret_permissions(tmpdir):
secret_file = tmpdir.join('cookie_secret')
secret_path = str(secret_file)
secret = os.urandom(1024)
secret_file.write(binascii.b2a_base64(secret))
hub = MockHub(cookie_secret_file=secret_path)
# raise with public secret file
os.chmod(secret_path, 0o664)
with pytest.raises(SystemExit):
hub.init_secrets()
# ok with same file, proper permissions
os.chmod(secret_path, 0o660)
hub.init_secrets()
assert hub.cookie_secret == secret
def test_cookie_secret_content(tmpdir):
secret_file = tmpdir.join('cookie_secret')
secret_file.write('not base 64: uñiço∂e')
secret_path = str(secret_file)
os.chmod(secret_path, 0o660)
hub = MockHub(cookie_secret_file=secret_path)
with pytest.raises(SystemExit):
hub.init_secrets()
def test_cookie_secret_env(tmpdir):
hub = MockHub(cookie_secret_file=str(tmpdir.join('cookie_secret')))
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'not hex'}):
with pytest.raises(ValueError):
hub.init_secrets()
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'abc123'}):
hub.init_secrets()
assert hub.cookie_secret == binascii.a2b_hex('abc123')
assert not os.path.exists(hub.cookie_secret_file)

View File

@@ -93,6 +93,16 @@ def test_tokens(db):
found = orm.APIToken.find(db, 'something else')
assert found is None
secret = 'super-secret-preload-token'
token = user.new_api_token(secret)
assert token == secret
assert len(user.api_tokens) == 3
# raise ValueError on collision
with pytest.raises(ValueError):
user.new_api_token(token)
assert len(user.api_tokens) == 3
def test_spawn_fails(db, io_loop):
orm_user = orm.User(name='aeofel')

View File

@@ -222,6 +222,16 @@ def test_logout(app):
assert r.cookies == {}
def test_login_no_whitelist_adds_user(app):
auth = app.authenticator
mock_add_user = mock.Mock()
with mock.patch.object(auth, 'add_user', mock_add_user):
cookies = app.login_user('jubal')
user = app.users['jubal']
assert mock_add_user.mock_calls == [mock.call(user)]
def test_static_files(app):
base_url = ujoin(public_url(app), app.hub.server.base_url)
print(base_url)

View File

@@ -21,7 +21,7 @@ class Command(List):
kwargs.setdefault('minlen', 1)
if isinstance(default_value, str):
default_value = [default_value]
super().__init__(Unicode, default_value, **kwargs)
super().__init__(Unicode(), default_value, **kwargs)
def validate(self, obj, value):
if isinstance(value, str):

View File

@@ -12,7 +12,7 @@ from sqlalchemy import inspect
from .utils import url_path_join
from . import orm
from traitlets import HasTraits, Any, Dict
from traitlets import HasTraits, Any, Dict, observe, default
from .spawner import LocalProcessSpawner
@@ -41,7 +41,7 @@ class UserDict(dict):
elif isinstance(key, str):
orm_user = self.db.query(orm.User).filter(orm.User.name==key).first()
if orm_user is None:
raise KeyError("No such user: %s" % name)
raise KeyError("No such user: %s" % key)
else:
key = orm_user
if isinstance(key, orm.User):
@@ -75,22 +75,24 @@ class UserDict(dict):
class User(HasTraits):
@default('log')
def _log_default(self):
return app_log
settings = Dict()
db = Any(allow_none=True)
@default('db')
def _db_default(self):
if self.orm_user:
return inspect(self.orm_user).session
def _db_changed(self, name, old, new):
@observe('db')
def _db_changed(self, change):
"""Changing db session reacquires ORM User object"""
# db session changed, re-get orm User
if self.orm_user:
id = self.orm_user.id
self.orm_user = new.query(orm.User).filter(orm.User.id==id).first()
self.orm_user = change['new'].query(orm.User).filter(orm.User.id==id).first()
self.spawner.db = self.db
orm_user = None
@@ -186,10 +188,10 @@ class User(HasTraits):
if self.settings.get('subdomain_host'):
return '{host}{path}'.format(
host=self.host,
path=self.server.base_url,
path=self.base_url,
)
else:
return self.server.base_url
return self.base_url
@gen.coroutine
def spawn(self, options=None):
@@ -226,7 +228,7 @@ class User(HasTraits):
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
except Exception as e:
if isinstance(e, gen.TimeoutError):
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
self.log.warning("{user}'s server failed to start in {s} seconds, giving up".format(
user=self.name, s=spawner.start_timeout,
))
e.reason = 'timeout'
@@ -254,7 +256,7 @@ class User(HasTraits):
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
except Exception as e:
if isinstance(e, TimeoutError):
self.log.warn(
self.log.warning(
"{user}'s server never showed up at {url} "
"after {http_timeout} seconds. Giving up".format(
user=self.name,

View File

@@ -78,14 +78,14 @@ def wait_for_http_server(url, timeout=10):
if e.code != 599:
# we expect 599 for no connection,
# but 502 or other proxy error is conceivable
app_log.warn("Server at %s responded with error: %s", url, e.code)
app_log.warning("Server at %s responded with error: %s", url, e.code)
yield gen.sleep(0.1)
else:
app_log.debug("Server at %s responded with %s", url, e.code)
return
except (OSError, socket.error) as e:
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
app_log.warn("Failed to connect to %s (%s)", url, e)
app_log.warning("Failed to connect to %s (%s)", url, e)
yield gen.sleep(0.1)
else:
return

View File

@@ -5,8 +5,9 @@
version_info = (
0,
5,
6,
0,
# 'dev',
)
__version__ = '.'.join(map(str, version_info))

11
onbuild/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# JupyterHub Dockerfile that loads your jupyterhub_config.py
#
# Adds ONBUILD step to jupyter/jupyterhub to load your juptyerhub_config.py into the image
#
# Derivative images must have jupyterhub_config.py next to the Dockerfile.
FROM jupyterhub/jupyterhub
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]

10
onbuild/README.md Normal file
View File

@@ -0,0 +1,10 @@
# JupyterHub onbuild image
If you base a Dockerfile on this image:
FROM juptyerhub/jupyterhub-onbuild:0.6
...
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.
This is how the `jupyter/jupyterhub` docker image behaved prior to 0.6.

View File

@@ -1,6 +1,7 @@
traitlets>=4
traitlets>=4.1
tornado>=4.1
jinja2
pamela
statsd
sqlalchemy>=1.0
requests

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/env python
"""Extend regular notebook server to be aware of multiuser things."""
# Copyright (c) Jupyter Development Team.

View File

@@ -166,7 +166,7 @@ class Bower(BaseCommand):
if self.should_run_npm():
print("installing build dependencies with npm")
check_call(['npm', 'install'], cwd=here)
check_call(['npm', 'install', '--progress=false'], cwd=here)
os.utime(self.node_modules)
env = os.environ.copy()

View File

@@ -10,7 +10,7 @@
{% endif %}
<a id="start" class="btn btn-lg btn-success"
{% if user.running %}
href="{{base_url}}user/{{user.name}}/"
href="{{ user.url }}"
{% else %}
href="{{base_url}}spawn"
{% endif %}

View File

@@ -133,6 +133,7 @@ def untag(vs, push=False):
v2 = parse_vs(vs)
v2.append('dev')
v2[1] += 1
v2[2] = 0
vs2 = unparse_vs(v2)
patch_version(vs2, repo_root)
with cd(repo_root):