mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
106 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3046971064 | ||
![]() |
30498f97c4 | ||
![]() |
d9d68efa55 | ||
![]() |
4125dc7ad0 | ||
![]() |
13600894fb | ||
![]() |
1b796cd871 | ||
![]() |
e7889dc12e | ||
![]() |
244a3b1000 | ||
![]() |
05dfda469f | ||
![]() |
6b19ee792d | ||
![]() |
ace38d744a | ||
![]() |
56a5ed8c87 | ||
![]() |
60e8a76476 | ||
![]() |
552800ceb7 | ||
![]() |
7dd1900f5f | ||
![]() |
35c261d0ed | ||
![]() |
fa34ce64b7 | ||
![]() |
f0504420a9 | ||
![]() |
8666f3a46c | ||
![]() |
60d6019cf7 | ||
![]() |
173daeeb09 | ||
![]() |
cf988dca4d | ||
![]() |
ffc2faabf7 | ||
![]() |
9fed0334c8 | ||
![]() |
8b61eb7347 | ||
![]() |
9cdda101c7 | ||
![]() |
f3bbca80ea | ||
![]() |
ce30f28449 | ||
![]() |
6cb58c17e7 | ||
![]() |
183e244490 | ||
![]() |
d5cd5115a5 | ||
![]() |
bbd3b22490 | ||
![]() |
e02daf01ad | ||
![]() |
af1e253f8a | ||
![]() |
491da69994 | ||
![]() |
0737600d3c | ||
![]() |
c7f542e79e | ||
![]() |
21213c97c6 | ||
![]() |
b36cd92ae6 | ||
![]() |
094ac451c7 | ||
![]() |
fa4b666693 | ||
![]() |
ce9dc2093c | ||
![]() |
9fd97a8d63 | ||
![]() |
2261a0e21d | ||
![]() |
a7a1c32a03 | ||
![]() |
dfd01bbf5f | ||
![]() |
b11a5be781 | ||
![]() |
8b6950055b | ||
![]() |
e8a298be00 | ||
![]() |
69f24acac2 | ||
![]() |
9ffebd0c5e | ||
![]() |
2dd3d3c448 | ||
![]() |
4644e7019e | ||
![]() |
5a15d7a219 | ||
![]() |
788129da12 | ||
![]() |
cac5175c9b | ||
![]() |
80556360ac | ||
![]() |
3dca0df55f | ||
![]() |
62a5e9dbce | ||
![]() |
45fcdc75c0 | ||
![]() |
f1bdf6247a | ||
![]() |
80932a51f4 | ||
![]() |
c8774c44d4 | ||
![]() |
bf2629450c | ||
![]() |
705ff78715 | ||
![]() |
a13119a79f | ||
![]() |
6932719e4e | ||
![]() |
68a750fc7a | ||
![]() |
c6d05d0840 | ||
![]() |
2bbfd75f4d | ||
![]() |
26f0e8ea5c | ||
![]() |
552e5caa11 | ||
![]() |
7753187e51 | ||
![]() |
bddadc7522 | ||
![]() |
195eea55f3 | ||
![]() |
7a2794af7c | ||
![]() |
fa48620076 | ||
![]() |
e4cfe01c4a | ||
![]() |
b35e506220 | ||
![]() |
dd3ed1bf75 | ||
![]() |
40368b8f55 | ||
![]() |
d0f1520642 | ||
![]() |
28c8265c3d | ||
![]() |
1d1a8ba78b | ||
![]() |
a1c764593c | ||
![]() |
06902afa2d | ||
![]() |
6d46f10cfa | ||
![]() |
b71f34eb3c | ||
![]() |
11df935f34 | ||
![]() |
19b6468889 | ||
![]() |
d2dddd6c82 | ||
![]() |
5d140fb889 | ||
![]() |
2bf8683905 | ||
![]() |
2dba7f4f61 | ||
![]() |
2820ba319f | ||
![]() |
be7a627c11 | ||
![]() |
2cb1618937 | ||
![]() |
c9e0c5fe04 | ||
![]() |
922956def2 | ||
![]() |
c6c699ea89 | ||
![]() |
e0219d0363 | ||
![]() |
f7dab558e4 | ||
![]() |
74e558dad2 | ||
![]() |
96269fac0f | ||
![]() |
a0501c6ee4 | ||
![]() |
ea2ed75ab2 |
@@ -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:
|
||||
|
41
Dockerfile
41
Dockerfile
@@ -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"]
|
||||
|
@@ -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
|
||||
|
27
README.md
27
README.md
@@ -3,9 +3,11 @@
|
||||
Questions, comments? Visit our Google Group:
|
||||
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
[](https://travis-ci.org/jupyter/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyter/jupyterhub)
|
||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
[](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
|
||||
|
||||
|
10
circle.yml
10
circle.yml
@@ -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}
|
||||
|
@@ -1,3 +1,3 @@
|
||||
-r ../requirements.txt
|
||||
sphinx
|
||||
sphinx>=1.3.6
|
||||
recommonmark==0.4.0
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -46,6 +46,7 @@ Contents:
|
||||
|
||||
getting-started
|
||||
howitworks
|
||||
websecurity
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
@@ -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.
|
||||
|
63
docs/source/websecurity.md
Normal file
63
docs/source/websecurity.md
Normal 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
|
@@ -1,4 +1,4 @@
|
||||
FROM jupyter/jupyterhub
|
||||
FROM jupyter/jupyterhub-onbuild
|
||||
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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),
|
||||
]
|
||||
|
@@ -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."""
|
||||
|
@@ -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):
|
||||
|
@@ -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)
|
||||
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)
|
||||
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.
|
||||
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):
|
||||
self.authenticator.whitelist.add(user.name)
|
||||
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).
|
||||
|
||||
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()
|
||||
|
||||
for user in new_users:
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
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)
|
||||
|
@@ -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
13
jupyterhub/emptyclass.py
Normal 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
|
@@ -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),
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
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
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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')
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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')
|
||||
|
@@ -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)
|
||||
|
@@ -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):
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -5,8 +5,9 @@
|
||||
|
||||
version_info = (
|
||||
0,
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
# 'dev',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
11
onbuild/Dockerfile
Normal file
11
onbuild/Dockerfile
Normal 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
10
onbuild/README.md
Normal 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.
|
@@ -1,6 +1,7 @@
|
||||
traitlets>=4
|
||||
traitlets>=4.1
|
||||
tornado>=4.1
|
||||
jinja2
|
||||
pamela
|
||||
statsd
|
||||
sqlalchemy>=1.0
|
||||
requests
|
||||
|
@@ -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.
|
||||
|
2
setup.py
2
setup.py
@@ -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()
|
||||
|
@@ -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 %}
|
||||
|
@@ -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):
|
||||
|
Reference in New Issue
Block a user