mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 02:24:08 +00:00
Compare commits
214 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2985562c2f | ||
![]() |
754f850e95 | ||
![]() |
dccb85d225 | ||
![]() |
a0e401bc87 | ||
![]() |
c6885a2124 | ||
![]() |
7528fb7d9b | ||
![]() |
e7df5a299c | ||
![]() |
ff997bbce5 | ||
![]() |
1e21e00e1a | ||
![]() |
77d3ee98f9 | ||
![]() |
1f861b2c90 | ||
![]() |
14a00e67b4 | ||
![]() |
14f63c168d | ||
![]() |
e70dbb3d32 | ||
![]() |
b679275a68 | ||
![]() |
0c1478a67e | ||
![]() |
d26e2346a2 | ||
![]() |
9a09c841b9 | ||
![]() |
f1d4f5a733 | ||
![]() |
d970dd4c89 | ||
![]() |
f3279bf849 | ||
![]() |
db0878a495 | ||
![]() |
c9b1042791 | ||
![]() |
cd81320d8f | ||
![]() |
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 | ||
![]() |
fc6435825c | ||
![]() |
b3ab48eb68 | ||
![]() |
a212151c09 | ||
![]() |
67ccfc7eb7 | ||
![]() |
9af103c673 | ||
![]() |
82643adfb6 | ||
![]() |
74df94d15a | ||
![]() |
da1b9bdd80 | ||
![]() |
18675ef6df | ||
![]() |
bf9dea5522 | ||
![]() |
62e30c1d79 | ||
![]() |
1316196542 | ||
![]() |
1a377bd03a | ||
![]() |
66a99ce881 | ||
![]() |
481debcb80 | ||
![]() |
03c25b5cac | ||
![]() |
26c060d2c5 | ||
![]() |
7ff42f9b55 | ||
![]() |
a35d8a6262 | ||
![]() |
8f39e1f8f9 | ||
![]() |
ff19b799c4 | ||
![]() |
e547949aee | ||
![]() |
31be00b49f | ||
![]() |
4533d96002 | ||
![]() |
7f89f1a2a0 | ||
![]() |
aed29e1db8 | ||
![]() |
49bee25820 | ||
![]() |
838c8eb057 | ||
![]() |
be5860822d | ||
![]() |
5a10d304c9 | ||
![]() |
fdd3746f54 | ||
![]() |
4d55a48a79 | ||
![]() |
b2ece48239 | ||
![]() |
6375ba30b7 | ||
![]() |
f565f8ac53 | ||
![]() |
5ec05822f1 | ||
![]() |
335b47d7c1 | ||
![]() |
f922561003 | ||
![]() |
79df83f0d3 | ||
![]() |
29416463ff | ||
![]() |
dd2e1ef758 | ||
![]() |
a9b8542ec7 | ||
![]() |
a4ae2ec2d8 | ||
![]() |
b54bfad8c2 | ||
![]() |
724bf7c4ce | ||
![]() |
fccc954fb4 | ||
![]() |
74385a6906 | ||
![]() |
dd66fe63c0 | ||
![]() |
e74934cb17 | ||
![]() |
450281a90a | ||
![]() |
6e7fc0574e | ||
![]() |
fc49aac02b | ||
![]() |
097d883905 | ||
![]() |
cb55118f70 | ||
![]() |
2a3c87945e | ||
![]() |
2b2aacedc6 | ||
![]() |
8ebec52827 | ||
![]() |
1642cc30c8 | ||
![]() |
1645d8f0c0 | ||
![]() |
8d390819a1 | ||
![]() |
c7dd18bb03 | ||
![]() |
84b7de4d21 | ||
![]() |
161df53143 | ||
![]() |
1cfd6cf12e | ||
![]() |
d40dcc35fb | ||
![]() |
a570e95602 | ||
![]() |
e4e43521ee | ||
![]() |
1b2c21a99c | ||
![]() |
e28eda6386 | ||
![]() |
39c171cce7 | ||
![]() |
c81cefd768 | ||
![]() |
325f137265 | ||
![]() |
1ae795df18 | ||
![]() |
2aacd5e28b | ||
![]() |
6e1425e2c0 | ||
![]() |
010db6ce72 | ||
![]() |
ce8d782220 | ||
![]() |
90c2b23fc0 | ||
![]() |
32685aeac1 | ||
![]() |
01c5608104 | ||
![]() |
a35f6298f0 | ||
![]() |
8955d6aed4 | ||
![]() |
cafbf8b990 | ||
![]() |
f626d2f6e5 |
@@ -12,6 +12,10 @@ before_install:
|
|||||||
install:
|
install:
|
||||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||||
script:
|
script:
|
||||||
- py.test --cov jupyterhub jupyterhub/tests -v
|
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
|
||||||
after_success:
|
after_success:
|
||||||
- codecov
|
- codecov
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- python: 3.5
|
||||||
|
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000
|
||||||
|
43
Dockerfile
43
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
|
FROM debian:jessie
|
||||||
|
|
||||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||||
|
|
||||||
# install nodejs, utf8 locale
|
# install nodejs, utf8 locale
|
||||||
@@ -22,7 +37,8 @@ RUN apt-get -y update && \
|
|||||||
ENV LANG C.UTF-8
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
# install Python with conda
|
# 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 && \
|
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/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \
|
||||||
/opt/conda/bin/pip install --upgrade pip && \
|
/opt/conda/bin/pip install --upgrade pip && \
|
||||||
@@ -32,19 +48,16 @@ ENV PATH=/opt/conda/bin:$PATH
|
|||||||
# install js dependencies
|
# install js dependencies
|
||||||
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
||||||
|
|
||||||
WORKDIR /srv/
|
ADD . /src/jupyterhub
|
||||||
ADD . /srv/jupyterhub
|
WORKDIR /src/jupyterhub
|
||||||
WORKDIR /srv/jupyterhub/
|
|
||||||
|
|
||||||
RUN python setup.py js && pip install . && \
|
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/
|
WORKDIR /srv/jupyterhub/
|
||||||
|
|
||||||
# Derivative containers should add jupyterhub config,
|
|
||||||
# which will be used when starting the application.
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
LABEL org.jupyter.service="jupyterhub"
|
||||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
|
||||||
|
CMD ["jupyterhub"]
|
||||||
|
@@ -4,7 +4,9 @@ include setupegg.py
|
|||||||
include bower.json
|
include bower.json
|
||||||
include package.json
|
include package.json
|
||||||
include *requirements.txt
|
include *requirements.txt
|
||||||
|
include Dockerfile
|
||||||
|
|
||||||
|
graft onbuild
|
||||||
graft jupyterhub
|
graft jupyterhub
|
||||||
graft scripts
|
graft scripts
|
||||||
graft share
|
graft share
|
||||||
|
44
README.md
44
README.md
@@ -3,9 +3,11 @@
|
|||||||
Questions, comments? Visit our Google Group:
|
Questions, comments? Visit our Google Group:
|
||||||
|
|
||||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||||
[](https://travis-ci.org/jupyter/jupyterhub)
|
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||||
[](https://circleci.com/gh/jupyter/jupyterhub)
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||||
|
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||||
|
|
||||||
|
|
||||||
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ Basic principles:
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
JupyterHub requires [IPython](https://ipython.org/install.html) >= 3.0 (current master) and [Python](https://www.python.org/downloads/) >= 3.3.
|
JupyterHub itself requires [Python](https://www.python.org/downloads/) ≥ 3.3. To run the single-user servers (which may be on the same system as the Hub or not), [Jupyter Notebook](https://jupyter.readthedocs.org/en/latest/install.html) ≥ 4 is required.
|
||||||
|
|
||||||
Install [nodejs/npm](https://www.npmjs.com/), which is available from your
|
Install [nodejs/npm](https://www.npmjs.com/), which is available from your
|
||||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
||||||
@@ -50,21 +52,22 @@ Notes on the `pip` command used in the installation directions below:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
JupyterHub can be installed with pip:
|
JupyterHub can be installed with pip, and the proxy with npm:
|
||||||
|
|
||||||
|
npm install -g configurable-http-proxy
|
||||||
pip3 install jupyterhub
|
pip3 install jupyterhub
|
||||||
|
|
||||||
If you plan to run notebook servers locally, you may also need to install the
|
If you plan to run notebook servers locally, you may also need to install the
|
||||||
Jupyter ~~IPython~~ notebook:
|
Jupyter ~~IPython~~ notebook:
|
||||||
|
|
||||||
pip3 install notebook
|
pip3 install --upgrade notebook
|
||||||
|
|
||||||
|
|
||||||
### Development install
|
### Development install
|
||||||
|
|
||||||
For a development install, clone the repository and then install from source:
|
For a development install, clone the repository and then install from source:
|
||||||
|
|
||||||
git clone https://github.com/jupyter/jupyterhub
|
git clone https://github.com/jupyterhub/jupyterhub
|
||||||
cd jupyterhub
|
cd jupyterhub
|
||||||
pip3 install -r dev-requirements.txt -e .
|
pip3 install -r dev-requirements.txt -e .
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ and then visit `http://localhost:8000`, and sign in with your unix credentials.
|
|||||||
|
|
||||||
To allow multiple users to sign into the server, you will need to
|
To allow multiple users to sign into the server, you will need to
|
||||||
run the `jupyterhub` command as a *privileged user*, such as root.
|
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||||
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||||
describes how to run the server as a *less privileged user*, which requires more
|
describes how to run the server as a *less privileged user*, which requires more
|
||||||
configuration of the system.
|
configuration of the system.
|
||||||
|
|
||||||
@@ -113,8 +116,29 @@ The authentication and process spawning mechanisms can be replaced,
|
|||||||
which should allow plugging into a variety of authentication or process control environments.
|
which should allow plugging into a variety of authentication or process control environments.
|
||||||
Some examples, meant as illustration and testing of this concept:
|
Some examples, meant as illustration and testing of this concept:
|
||||||
|
|
||||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
There is a ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/).
|
||||||
|
[Note: This `jupyterhub/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.]
|
||||||
|
|
||||||
|
The JupyterHub docker image can be started with the following command:
|
||||||
|
|
||||||
|
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.
|
||||||
|
[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 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 (for testing purposes only), you'll need to set `--no-ssl` explicitly.
|
||||||
|
|
||||||
# Getting help
|
# Getting help
|
||||||
|
|
||||||
@@ -124,7 +148,7 @@ We encourage you to ask questions on the mailing list:
|
|||||||
|
|
||||||
and you may participate in development discussions or get live help on Gitter:
|
and you may participate in development discussions or get live help on Gitter:
|
||||||
|
|
||||||
[](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge)
|
[](https://gitter.im/jupyterhub/jupyterhub?utm_source=badge&utm_medium=badge)
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
|
15
circle.yml
15
circle.yml
@@ -8,4 +8,17 @@ dependencies:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
override:
|
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
|
||||||
|
release:
|
||||||
|
tag: /.*/
|
||||||
|
commands:
|
||||||
|
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
|
||||||
|
- docker push jupyterhub/jupyterhub-onbuild:$CIRCLE_TAG
|
||||||
|
@@ -1,3 +1,3 @@
|
|||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
sphinx
|
sphinx>=1.3.6
|
||||||
recommonmark==0.4.0
|
recommonmark==0.4.0
|
@@ -77,6 +77,7 @@ For simple mappings, a configurable dict `Authenticator.username_map` is used to
|
|||||||
c.Authenticator.username_map = {
|
c.Authenticator.username_map = {
|
||||||
'service-name': 'localname'
|
'service-name': 'localname'
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Validating usernames
|
### Validating usernames
|
||||||
|
|
||||||
|
@@ -2,6 +2,43 @@
|
|||||||
|
|
||||||
See `git log` for a more detailed summary.
|
See `git log` for a more detailed summary.
|
||||||
|
|
||||||
|
## 0.6
|
||||||
|
|
||||||
|
### 0.6.1
|
||||||
|
|
||||||
|
Bugfixes on 0.6:
|
||||||
|
|
||||||
|
- statsd is an optional dependency, only needed if in use
|
||||||
|
- Notice more quickly when servers have crashed
|
||||||
|
- Better error pages for proxy errors
|
||||||
|
- Add Stop All button to admin panel for stopping all servers at once
|
||||||
|
|
||||||
|
### 0.6.0
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
- Single-user server must be run with Jupyter Notebook ≥ 4.0
|
||||||
|
- Require `--no-ssl` confirmation to allow the Hub to be run without SSL (e.g. behind SSL termination in nginx)
|
||||||
|
- Add lengths to text fields for MySQL support
|
||||||
|
- Add `Spawner.disable_user_config` for preventing user-owned configuration from modifying single-user servers.
|
||||||
|
- Fixes for MySQL support.
|
||||||
|
- Add ability to run each user's server on its own subdomain. Requires wildcard DNS and wildcard SSL to be feasible. Enable subdomains by setting `JupyterHub.subdomain_host = 'https://jupyterhub.domain.tld[:port]'`.
|
||||||
|
- Use `127.0.0.1` for local communication instead of `localhost`, avoiding issues with DNS on some systems.
|
||||||
|
- Fix race that could add users to proxy prematurely if spawning is slow.
|
||||||
|
|
||||||
## 0.4
|
## 0.4
|
||||||
|
|
||||||
### 0.4.1
|
### 0.4.1
|
||||||
|
@@ -22,6 +22,11 @@ There are three main categories of processes run by the `jupyterhub` command lin
|
|||||||
|
|
||||||
## JupyterHub's default behavior
|
## JupyterHub's default behavior
|
||||||
|
|
||||||
|
**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, 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:
|
To start JupyterHub in its default configuration, type the following at the command line:
|
||||||
|
|
||||||
@@ -44,10 +49,6 @@ or any other public IP or domain pointing to your system.
|
|||||||
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
|
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
|
||||||
all communicate with each other on localhost only.
|
all communicate with each other on localhost only.
|
||||||
|
|
||||||
**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
|
|
||||||
You should not run JupyterHub without SSL encryption on a public network.
|
|
||||||
See [Security documentation](#Security) for how to configure JupyterHub to use SSL.
|
|
||||||
|
|
||||||
By default, starting JupyterHub will write two files to disk in the current working directory:
|
By default, starting JupyterHub will write two files to disk in the current working directory:
|
||||||
|
|
||||||
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the **Hub**.
|
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the **Hub**.
|
||||||
@@ -65,23 +66,13 @@ The location of these files can be specified via configuration, discussed below.
|
|||||||
|
|
||||||
JupyterHub is configured in two ways:
|
JupyterHub is configured in two ways:
|
||||||
|
|
||||||
1. Command-line arguments
|
1. Configuration file
|
||||||
2. Configuration files
|
2. Command-line arguments
|
||||||
|
|
||||||
Type the following for brief information about the command line arguments:
|
### Configuration file
|
||||||
|
By default, JupyterHub will look for a configuration file (which may not be created yet)
|
||||||
jupyterhub -h
|
|
||||||
|
|
||||||
or:
|
|
||||||
|
|
||||||
jupyterhub --help-all
|
|
||||||
|
|
||||||
for the full command line help.
|
|
||||||
|
|
||||||
By default, JupyterHub will look for a configuration file (can be missing)
|
|
||||||
named `jupyterhub_config.py` in the current working directory.
|
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
|
jupyterhub --generate-config
|
||||||
|
|
||||||
@@ -93,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)
|
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||||
on the config system Jupyter uses.
|
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
|
## Networking
|
||||||
|
|
||||||
@@ -148,6 +156,9 @@ c.JupyterHub.hub_port = 54321
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
**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
|
Security is the most important aspect of configuring Jupyter. There are three main aspects of the
|
||||||
security configuration:
|
security configuration:
|
||||||
|
|
||||||
@@ -179,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
|
these files be put in a secure location on your server, where they are not readable by regular
|
||||||
users.
|
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
|
## Cookie secret
|
||||||
|
|
||||||
The cookie secret is an encryption key, used to encrypt the browser cookies used for
|
The cookie secret is an encryption key, used to encrypt the browser cookies used for
|
||||||
@@ -190,26 +205,36 @@ as follows:
|
|||||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
|
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
|
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
|
||||||
file as:
|
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
the Hub starts, a new cookie secret is generated and stored in the file. The
|
||||||
permissions for the cookie secret file should be 600 (owner-only rw).
|
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
|
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
|
```bash
|
||||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||||
```
|
```
|
||||||
|
|
||||||
For security reasons, this environment variable should only be visible to the Hub.
|
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
|
## Proxy authentication token
|
||||||
|
|
||||||
|
@@ -1,22 +1,34 @@
|
|||||||
.. JupyterHub documentation master file, created by
|
|
||||||
sphinx-quickstart on Mon Jan 4 16:31:09 2016.
|
|
||||||
You can adapt this file completely to your liking, but it should at least
|
|
||||||
contain the root `toctree` directive.
|
|
||||||
|
|
||||||
JupyterHub
|
JupyterHub
|
||||||
==========
|
==========
|
||||||
|
|
||||||
.. note:: This is the official documentation for JupyterHub. This project is
|
JupyterHub is a server that gives multiple users access to Jupyter notebooks,
|
||||||
under active development.
|
running an independent Jupyter notebook server for each user.
|
||||||
|
|
||||||
JupyterHub is a multi-user server that manages and proxies multiple instances
|
To use JupyterHub, you need a Unix server (typically Linux) running
|
||||||
of the single-user Jupyter notebook server.
|
somewhere that is accessible to your team on the network. The JupyterHub server
|
||||||
|
can be on an internal network at your organisation, or it can run on the public
|
||||||
|
internet (in which case, take care with `security <getting-started.html#security>`__).
|
||||||
|
Users access JupyterHub in a web browser, by going to the IP address or
|
||||||
|
domain name of the server.
|
||||||
|
|
||||||
Three actors:
|
Different :doc:`authenticators <authenticators>` control access
|
||||||
|
to JupyterHub. The default one (pam) uses the user accounts on the server where
|
||||||
|
JupyterHub is running. If you use this, you will need to create a user account
|
||||||
|
on the system for each user on your team. Using other authenticators, you can
|
||||||
|
allow users to sign in with e.g. a Github account, or with any single-sign-on
|
||||||
|
system your organisation has.
|
||||||
|
|
||||||
* multi-user Hub (tornado process)
|
Next, :doc:`spawners <spawners>` control how JupyterHub starts
|
||||||
* `configurable http proxy <https://github.com/jupyter/configurable-http-proxy>`_ (node-http-proxy)
|
the individual notebook server for each user. The default spawner will
|
||||||
* multiple single-user IPython notebook servers (Python/IPython/tornado)
|
start a notebook server on the same machine running under their system username.
|
||||||
|
The other main option is to start each server in a separate container, often
|
||||||
|
using Docker.
|
||||||
|
|
||||||
|
JupyterHub runs as three separate parts:
|
||||||
|
|
||||||
|
* The multi-user Hub (Python & Tornado)
|
||||||
|
* A `configurable http proxy <https://github.com/jupyter/configurable-http-proxy>`_ (NodeJS)
|
||||||
|
* Multiple single-user Jupyter notebook servers (Python & Tornado)
|
||||||
|
|
||||||
Basic principles:
|
Basic principles:
|
||||||
|
|
||||||
@@ -34,6 +46,7 @@ Contents:
|
|||||||
|
|
||||||
getting-started
|
getting-started
|
||||||
howitworks
|
howitworks
|
||||||
|
websecurity
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
@@ -2,10 +2,98 @@
|
|||||||
|
|
||||||
This document is under active development.
|
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
|
- check if the JupyterHub IP configuration setting is
|
||||||
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
|
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
|
||||||
- Try starting with ``jupyterhub --ip=0.0.0.0``
|
- 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_user_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>
|
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
|
import shlex
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
import json
|
import json
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web, gen
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
@@ -20,13 +20,25 @@ class TokenAPIHandler(APIHandler):
|
|||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
|
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):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name, cookie_value=None):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
cookie_name = quote(cookie_name, safe='')
|
cookie_name = quote(cookie_name, safe='')
|
||||||
if cookie_value is None:
|
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
|
cookie_value = self.request.body
|
||||||
else:
|
else:
|
||||||
cookie_value = cookie_value.encode('utf8')
|
cookie_value = cookie_value.encode('utf8')
|
||||||
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
|
|||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
(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.
|
# If no header is provided, assume it comes from a script/curl.
|
||||||
# We are only concerned with cross-site browser stuff here.
|
# We are only concerned with cross-site browser stuff here.
|
||||||
if not host:
|
if not host:
|
||||||
self.log.warn("Blocking API request with no host")
|
self.log.warning("Blocking API request with no host")
|
||||||
return False
|
return False
|
||||||
if not referer:
|
if not referer:
|
||||||
self.log.warn("Blocking API request with no referer")
|
self.log.warning("Blocking API request with no referer")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
host_path = url_path_join(host, self.hub.server.base_url)
|
host_path = url_path_join(host, self.hub.server.base_url)
|
||||||
referer_path = referer.split('://', 1)[-1]
|
referer_path = referer.split('://', 1)[-1]
|
||||||
if not (referer_path + '/').startswith(host_path):
|
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)
|
referer, host_path)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
"""Override get_user_cookie to check Referer header"""
|
"""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 None
|
||||||
return super().get_current_user_cookie()
|
return cookie_user
|
||||||
|
|
||||||
def get_json_body(self):
|
def get_json_body(self):
|
||||||
"""Return the body of the request as JSON data."""
|
"""Return the body of the request as JSON data."""
|
||||||
@@ -86,7 +90,7 @@ class APIHandler(BaseHandler):
|
|||||||
model = {
|
model = {
|
||||||
'name': user.name,
|
'name': user.name,
|
||||||
'admin': user.admin,
|
'admin': user.admin,
|
||||||
'server': user.server.base_url if user.running else None,
|
'server': user.url if user.running else None,
|
||||||
'pending': None,
|
'pending': None,
|
||||||
'last_activity': user.last_activity.isoformat(),
|
'last_activity': user.last_activity.isoformat(),
|
||||||
}
|
}
|
||||||
|
@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def post(self):
|
def post(self):
|
||||||
"""POST checks the proxy to ensure"""
|
"""POST checks the proxy to ensure"""
|
||||||
yield self.proxy.check_routes()
|
yield self.proxy.check_routes(self.users)
|
||||||
|
|
||||||
|
|
||||||
@admin_only
|
@admin_only
|
||||||
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
self.proxy.auth_token = model['auth_token']
|
self.proxy.auth_token = model['auth_token']
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.log.info("Updated proxy at %s", server.bind_url)
|
self.log.info("Updated proxy at %s", server.bind_url)
|
||||||
yield self.proxy.check_routes()
|
yield self.proxy.check_routes(self.users)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@ -41,7 +41,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
continue
|
continue
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
self.log.warn("User %s already exists" % name)
|
self.log.warning("User %s already exists" % name)
|
||||||
else:
|
else:
|
||||||
to_create.append(name)
|
to_create.append(name)
|
||||||
|
|
||||||
@@ -161,8 +161,9 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
@admin_or_self
|
@admin_or_self
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user.spawner:
|
if user.running:
|
||||||
state = yield user.spawner.poll()
|
# include notify, so that a server that died is noticed immediately
|
||||||
|
state = yield user.spawner.poll_and_notify()
|
||||||
if state is None:
|
if state is None:
|
||||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||||
|
|
||||||
@@ -180,7 +181,8 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
return
|
return
|
||||||
if not user.running:
|
if not user.running:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||||
status = yield user.spawner.poll()
|
# include notify, so that a server that died is noticed immediately
|
||||||
|
status = yield user.spawner.poll_and_notify()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||||
yield self.stop_single_user(user)
|
yield self.stop_single_user(user)
|
||||||
@@ -195,7 +197,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
|||||||
@admin_only
|
@admin_only
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
current = self.get_current_user()
|
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,
|
current.name, name,
|
||||||
)
|
)
|
||||||
if not self.settings.get('admin_access', False):
|
if not self.settings.get('admin_access', False):
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ from tornado import gen
|
|||||||
import pamela
|
import pamela
|
||||||
|
|
||||||
from traitlets.config import LoggingConfigurable
|
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 .handlers.login import LoginHandler
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
@@ -29,19 +29,19 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
db = Any()
|
db = Any()
|
||||||
admin_users = Set(config=True,
|
admin_users = Set(
|
||||||
help="""set of usernames of admin users
|
help="""set of usernames of admin users
|
||||||
|
|
||||||
If unspecified, only the user that launches the server will be admin.
|
If unspecified, only the user that launches the server will be admin.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
whitelist = Set(config=True,
|
whitelist = Set(
|
||||||
help="""Username whitelist.
|
help="""Username whitelist.
|
||||||
|
|
||||||
Use this to restrict which users can login.
|
Use this to restrict which users can login.
|
||||||
If empty, allow any user to attempt login.
|
If empty, allow any user to attempt login.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
custom_html = Unicode('',
|
custom_html = Unicode('',
|
||||||
help="""HTML login form for custom handlers.
|
help="""HTML login form for custom handlers.
|
||||||
Override in form-based custom authenticators
|
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.
|
help="""Regular expression pattern for validating usernames.
|
||||||
|
|
||||||
If not defined: allow any username.
|
If not defined: allow any username.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
def _username_pattern_changed(self, name, old, new):
|
@observe('username_pattern')
|
||||||
if not new:
|
def _username_pattern_changed(self, change):
|
||||||
|
if not change['new']:
|
||||||
self.username_regex = None
|
self.username_regex = None
|
||||||
self.username_regex = re.compile(new)
|
self.username_regex = re.compile(change['new'])
|
||||||
|
|
||||||
username_regex = Any()
|
username_regex = Any()
|
||||||
|
|
||||||
@@ -77,14 +78,14 @@ class Authenticator(LoggingConfigurable):
|
|||||||
return True
|
return True
|
||||||
return bool(self.username_regex.match(username))
|
return bool(self.username_regex.match(username))
|
||||||
|
|
||||||
username_map = Dict(config=True,
|
username_map = Dict(
|
||||||
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
||||||
|
|
||||||
Can be used to map OAuth service names to local users, for instance.
|
Can be used to map OAuth service names to local users, for instance.
|
||||||
|
|
||||||
Used in normalize_username.
|
Used in normalize_username.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
def normalize_username(self, username):
|
def normalize_username(self, username):
|
||||||
"""Normalize a username.
|
"""Normalize a username.
|
||||||
@@ -246,12 +247,12 @@ class LocalAuthenticator(Authenticator):
|
|||||||
Checks for local users, and can attempt to create them if they exist.
|
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,
|
help="""If a user is added that doesn't exist on the system,
|
||||||
should I try to create the system user?
|
should I try to create the system user?
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
add_user_cmd = Command(config=True,
|
add_user_cmd = Command(
|
||||||
help="""The command to use for creating users as a list of strings.
|
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
|
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.
|
when the user 'river' is created.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@default('add_user_cmd')
|
||||||
def _add_user_cmd_default(self):
|
def _add_user_cmd_default(self):
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
raise ValueError("I don't know how to create users on OS X")
|
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']
|
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||||
|
|
||||||
group_whitelist = Set(
|
group_whitelist = Set(
|
||||||
config=True,
|
|
||||||
help="Automatically whitelist anyone in this group.",
|
help="Automatically whitelist anyone in this group.",
|
||||||
)
|
).tag(config=True)
|
||||||
|
@observe('group_whitelist')
|
||||||
def _group_whitelist_changed(self, name, old, new):
|
def _group_whitelist_changed(self, change):
|
||||||
if self.whitelist:
|
if self.whitelist:
|
||||||
self.log.warn(
|
self.log.warning(
|
||||||
"Ignoring username whitelist because group whitelist supplied!"
|
"Ignoring username whitelist because group whitelist supplied!"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -351,12 +353,24 @@ class LocalAuthenticator(Authenticator):
|
|||||||
|
|
||||||
class PAMAuthenticator(LocalAuthenticator):
|
class PAMAuthenticator(LocalAuthenticator):
|
||||||
"""Authenticate local Linux/UNIX users with PAM"""
|
"""Authenticate local Linux/UNIX users with PAM"""
|
||||||
encoding = Unicode('utf8', config=True,
|
encoding = Unicode('utf8',
|
||||||
help="""The encoding to use for PAM"""
|
help="""The encoding to use for PAM"""
|
||||||
)
|
).tag(config=True)
|
||||||
service = Unicode('login', config=True,
|
service = Unicode('login',
|
||||||
help="""The PAM service to use for authentication."""
|
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
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
def authenticate(self, handler, data):
|
||||||
@@ -369,23 +383,31 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
pamela.authenticate(username, data['password'], service=self.service)
|
pamela.authenticate(username, data['password'], service=self.service)
|
||||||
except pamela.PAMError as e:
|
except pamela.PAMError as e:
|
||||||
if handler is not None:
|
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:
|
else:
|
||||||
self.log.warn("PAM Authentication failed: %s", e)
|
self.log.warning("PAM Authentication failed: %s", e)
|
||||||
else:
|
else:
|
||||||
return username
|
return username
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
def pre_spawn_start(self, user, spawner):
|
||||||
"""Open PAM session for user"""
|
"""Open PAM session for user"""
|
||||||
|
if not self.open_sessions:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
pamela.open_session(user.name, service=self.service)
|
pamela.open_session(user.name, service=self.service)
|
||||||
except pamela.PAMError as e:
|
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):
|
def post_spawn_stop(self, user, spawner):
|
||||||
"""Close PAM session for user"""
|
"""Close PAM session for user"""
|
||||||
|
if not self.open_sessions:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
pamela.close_session(user.name, service=self.service)
|
pamela.close_session(user.name, service=self.service)
|
||||||
except pamela.PAMError as e:
|
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
|
@@ -46,27 +46,39 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def base_url(self):
|
def base_url(self):
|
||||||
return self.settings.get('base_url', '/')
|
return self.settings.get('base_url', '/')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def version_hash(self):
|
def version_hash(self):
|
||||||
return self.settings.get('version_hash', '')
|
return self.settings.get('version_hash', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subdomain_host(self):
|
||||||
|
return self.settings.get('subdomain_host', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain(self):
|
||||||
|
return self.settings['domain']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.settings['db']
|
return self.settings['db']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def users(self):
|
def users(self):
|
||||||
return self.settings.setdefault('users', {})
|
return self.settings.setdefault('users', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hub(self):
|
def hub(self):
|
||||||
return self.settings['hub']
|
return self.settings['hub']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def proxy(self):
|
def proxy(self):
|
||||||
return self.settings['proxy']
|
return self.settings['proxy']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def statsd(self):
|
||||||
|
return self.settings['statsd']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def authenticator(self):
|
def authenticator(self):
|
||||||
return self.settings.get('authenticator', None)
|
return self.settings.get('authenticator', None)
|
||||||
@@ -75,28 +87,28 @@ class BaseHandler(RequestHandler):
|
|||||||
"""Roll back any uncommitted transactions from the handler."""
|
"""Roll back any uncommitted transactions from the handler."""
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
super().finish(*args, **kwargs)
|
super().finish(*args, **kwargs)
|
||||||
|
|
||||||
#---------------------------------------------------------------
|
#---------------------------------------------------------------
|
||||||
# Security policies
|
# Security policies
|
||||||
#---------------------------------------------------------------
|
#---------------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def csp_report_uri(self):
|
def csp_report_uri(self):
|
||||||
return self.settings.get('csp_report_uri',
|
return self.settings.get('csp_report_uri',
|
||||||
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def content_security_policy(self):
|
def content_security_policy(self):
|
||||||
"""The default Content-Security-Policy header
|
"""The default Content-Security-Policy header
|
||||||
|
|
||||||
Can be overridden by defining Content-Security-Policy in settings['headers']
|
Can be overridden by defining Content-Security-Policy in settings['headers']
|
||||||
"""
|
"""
|
||||||
return '; '.join([
|
return '; '.join([
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"report-uri " + self.csp_report_uri,
|
"report-uri " + self.csp_report_uri,
|
||||||
])
|
])
|
||||||
|
|
||||||
def set_default_headers(self):
|
def set_default_headers(self):
|
||||||
"""
|
"""
|
||||||
Set any headers passed as tornado_settings['headers'].
|
Set any headers passed as tornado_settings['headers'].
|
||||||
@@ -105,7 +117,7 @@ class BaseHandler(RequestHandler):
|
|||||||
"""
|
"""
|
||||||
headers = self.settings.get('headers', {})
|
headers = self.settings.get('headers', {})
|
||||||
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
||||||
|
|
||||||
for header_name, header_content in headers.items():
|
for header_name, header_content in headers.items():
|
||||||
self.set_header(header_name, header_content)
|
self.set_header(header_name, header_content)
|
||||||
|
|
||||||
@@ -116,7 +128,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def admin_users(self):
|
def admin_users(self):
|
||||||
return self.settings.setdefault('admin_users', set())
|
return self.settings.setdefault('admin_users', set())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_max_age_days(self):
|
def cookie_max_age_days(self):
|
||||||
return self.settings.get('cookie_max_age_days', None)
|
return self.settings.get('cookie_max_age_days', None)
|
||||||
@@ -133,7 +145,7 @@ class BaseHandler(RequestHandler):
|
|||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return orm_token.user
|
return orm_token.user
|
||||||
|
|
||||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
"""Get the User for a given cookie, if there is one"""
|
"""Get the User for a given cookie, if there is one"""
|
||||||
cookie_id = self.get_secure_cookie(
|
cookie_id = self.get_secure_cookie(
|
||||||
@@ -143,41 +155,41 @@ class BaseHandler(RequestHandler):
|
|||||||
)
|
)
|
||||||
def clear():
|
def clear():
|
||||||
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
||||||
|
|
||||||
if cookie_id is None:
|
if cookie_id is None:
|
||||||
if self.get_cookie(cookie_name):
|
if self.get_cookie(cookie_name):
|
||||||
self.log.warn("Invalid or expired cookie token")
|
self.log.warning("Invalid or expired cookie token")
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||||
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||||
user = self._user_from_orm(u)
|
user = self._user_from_orm(u)
|
||||||
if user is None:
|
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.
|
# have cookie, but it's not valid. Clear it and start over.
|
||||||
clear()
|
clear()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def _user_from_orm(self, orm_user):
|
def _user_from_orm(self, orm_user):
|
||||||
"""return User wrapper from orm.User object"""
|
"""return User wrapper from orm.User object"""
|
||||||
if orm_user is None:
|
if orm_user is None:
|
||||||
return
|
return
|
||||||
return self.users[orm_user]
|
return self.users[orm_user]
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
"""get_current_user from a cookie token"""
|
"""get_current_user from a cookie token"""
|
||||||
return self._user_for_cookie(self.hub.server.cookie_name)
|
return self._user_for_cookie(self.hub.server.cookie_name)
|
||||||
|
|
||||||
def get_current_user(self):
|
def get_current_user(self):
|
||||||
"""get current username"""
|
"""get current username"""
|
||||||
user = self.get_current_user_token()
|
user = self.get_current_user_token()
|
||||||
if user is not None:
|
if user is not None:
|
||||||
return user
|
return user
|
||||||
return self.get_current_user_cookie()
|
return self.get_current_user_cookie()
|
||||||
|
|
||||||
def find_user(self, name):
|
def find_user(self, name):
|
||||||
"""Get a user by name
|
"""Get a user by name
|
||||||
|
|
||||||
return None if no such user
|
return None if no such user
|
||||||
"""
|
"""
|
||||||
orm_user = orm.User.find(db=self.db, name=name)
|
orm_user = orm.User.find(db=self.db, name=name)
|
||||||
@@ -192,57 +204,60 @@ class BaseHandler(RequestHandler):
|
|||||||
self.db.add(u)
|
self.db.add(u)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
user = self._user_from_orm(u)
|
user = self._user_from_orm(u)
|
||||||
|
self.authenticator.add_user(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def clear_login_cookie(self, name=None):
|
def clear_login_cookie(self, name=None):
|
||||||
if name is None:
|
if name is None:
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
else:
|
else:
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
|
kwargs = {}
|
||||||
|
if self.subdomain_host:
|
||||||
|
kwargs['domain'] = self.domain
|
||||||
if user and user.server:
|
if user and user.server:
|
||||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
|
||||||
|
|
||||||
|
def _set_user_cookie(self, user, server):
|
||||||
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
|
if self.request.protocol == 'https':
|
||||||
|
kwargs = {'secure': True}
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
|
if self.subdomain_host:
|
||||||
|
kwargs['domain'] = self.domain
|
||||||
|
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
|
||||||
|
self.set_secure_cookie(
|
||||||
|
server.cookie_name,
|
||||||
|
user.cookie_id,
|
||||||
|
path=server.base_url,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
def set_server_cookie(self, user):
|
def set_server_cookie(self, user):
|
||||||
"""set the login cookie for the single-user server"""
|
"""set the login cookie for the single-user server"""
|
||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
self._set_user_cookie(user, user.server)
|
||||||
# 'secure' kwarg is passed to set_secure_cookie
|
|
||||||
if self.request.protocol == 'https':
|
|
||||||
kwargs = {'secure':True}
|
|
||||||
else:
|
|
||||||
kwargs = {}
|
|
||||||
self.set_secure_cookie(
|
|
||||||
user.server.cookie_name,
|
|
||||||
user.cookie_id,
|
|
||||||
path=user.server.base_url,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_hub_cookie(self, user):
|
def set_hub_cookie(self, user):
|
||||||
"""set the login cookie for the Hub"""
|
"""set the login cookie for the Hub"""
|
||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
self._set_user_cookie(user, self.hub.server)
|
||||||
# 'secure' kwarg is passed to set_secure_cookie
|
|
||||||
if self.request.protocol == 'https':
|
|
||||||
kwargs = {'secure':True}
|
|
||||||
else:
|
|
||||||
kwargs = {}
|
|
||||||
self.set_secure_cookie(
|
|
||||||
self.hub.server.cookie_name,
|
|
||||||
user.cookie_id,
|
|
||||||
path=self.hub.server.base_url,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_login_cookie(self, user):
|
def set_login_cookie(self, user):
|
||||||
"""Set login cookies for the Hub and single-user server."""
|
"""Set login cookies for the Hub and single-user server."""
|
||||||
|
if self.subdomain_host and not self.request.host.startswith(self.domain):
|
||||||
|
self.log.warning(
|
||||||
|
"Possibly setting cookie on wrong domain: %s != %s",
|
||||||
|
self.request.host, self.domain)
|
||||||
# create and set a new cookie token for the single-user server
|
# create and set a new cookie token for the single-user server
|
||||||
if user.server:
|
if user.server:
|
||||||
self.set_server_cookie(user)
|
self.set_server_cookie(user)
|
||||||
|
|
||||||
# create and set a new cookie token for the hub
|
# create and set a new cookie token for the hub
|
||||||
if not self.get_current_user_cookie():
|
if not self.get_current_user_cookie():
|
||||||
self.set_hub_cookie(user)
|
self.set_hub_cookie(user)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def authenticate(self, data):
|
def authenticate(self, data):
|
||||||
auth = self.authenticator
|
auth = self.authenticator
|
||||||
@@ -268,7 +283,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def spawner_class(self):
|
def spawner_class(self):
|
||||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def spawn_single_user(self, user, options=None):
|
def spawn_single_user(self, user, options=None):
|
||||||
if user.spawn_pending:
|
if user.spawn_pending:
|
||||||
@@ -280,7 +295,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def finish_user_spawn(f=None):
|
def finish_user_spawn(f=None):
|
||||||
"""Finish the user spawn by registering listeners and notifying the proxy.
|
"""Finish the user spawn by registering listeners and notifying the proxy.
|
||||||
|
|
||||||
If the spawner is slow to start, this is passed as an async callback,
|
If the spawner is slow to start, this is passed as an async callback,
|
||||||
otherwise it is called immediately.
|
otherwise it is called immediately.
|
||||||
"""
|
"""
|
||||||
@@ -289,38 +304,48 @@ class BaseHandler(RequestHandler):
|
|||||||
return
|
return
|
||||||
toc = IOLoop.current().time()
|
toc = IOLoop.current().time()
|
||||||
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
|
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)
|
yield self.proxy.add_user(user)
|
||||||
user.spawner.add_poll_callback(self.user_stopped, user)
|
user.spawner.add_poll_callback(self.user_stopped, user)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
if user.spawn_pending:
|
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.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:
|
||||||
|
# start has finished, but the server hasn't come up
|
||||||
|
# check if the server died while we were waiting
|
||||||
status = yield user.spawner.poll()
|
status = yield user.spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
# hit timeout, but spawn is still pending
|
# hit timeout, but server's running. Hope that it'll show up soon enough,
|
||||||
self.log.warn("User %s server is slow to start", user.name)
|
# though it's possible that it started at the wrong URL
|
||||||
|
self.log.warning("User %s server is slow to become responsive", user.name)
|
||||||
# schedule finish for when the user finishes spawning
|
# schedule finish for when the user finishes spawning
|
||||||
IOLoop.current().add_future(f, finish_user_spawn)
|
IOLoop.current().add_future(f, finish_user_spawn)
|
||||||
else:
|
else:
|
||||||
|
toc = IOLoop.current().time()
|
||||||
|
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
else:
|
||||||
yield finish_user_spawn()
|
yield finish_user_spawn()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def user_stopped(self, user):
|
def user_stopped(self, user):
|
||||||
"""Callback that fires when the spawner has stopped"""
|
"""Callback that fires when the spawner has stopped"""
|
||||||
status = yield user.spawner.poll()
|
status = yield user.spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
status = 'unknown'
|
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,
|
user.name, status,
|
||||||
)
|
)
|
||||||
yield self.proxy.delete_user(user)
|
yield self.proxy.delete_user(user)
|
||||||
yield user.stop()
|
yield user.stop()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop_single_user(self, user):
|
def stop_single_user(self, user):
|
||||||
if user.stop_pending:
|
if user.stop_pending:
|
||||||
@@ -331,7 +356,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def finish_stop(f=None):
|
def finish_stop(f=None):
|
||||||
"""Finish the stop action by noticing that the user is stopped.
|
"""Finish the stop action by noticing that the user is stopped.
|
||||||
|
|
||||||
If the spawner is slow to stop, this is passed as an async callback,
|
If the spawner is slow to stop, this is passed as an async callback,
|
||||||
otherwise it is called immediately.
|
otherwise it is called immediately.
|
||||||
"""
|
"""
|
||||||
@@ -340,13 +365,13 @@ class BaseHandler(RequestHandler):
|
|||||||
return
|
return
|
||||||
toc = IOLoop.current().time()
|
toc = IOLoop.current().time()
|
||||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc-tic)
|
self.log.info("User %s server took %.3f seconds to stop", user.name, toc-tic)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f)
|
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
if user.stop_pending:
|
if user.stop_pending:
|
||||||
# hit timeout, but stop is still 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
|
# schedule finish for when the server finishes stopping
|
||||||
IOLoop.current().add_future(f, finish_stop)
|
IOLoop.current().add_future(f, finish_stop)
|
||||||
else:
|
else:
|
||||||
@@ -385,6 +410,7 @@ class BaseHandler(RequestHandler):
|
|||||||
"""render custom error pages"""
|
"""render custom error pages"""
|
||||||
exc_info = kwargs.get('exc_info')
|
exc_info = kwargs.get('exc_info')
|
||||||
message = ''
|
message = ''
|
||||||
|
exception = None
|
||||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||||
if exc_info:
|
if exc_info:
|
||||||
exception = exc_info[1]
|
exception = exc_info[1]
|
||||||
@@ -426,7 +452,7 @@ class Template404(BaseHandler):
|
|||||||
|
|
||||||
class PrefixRedirectHandler(BaseHandler):
|
class PrefixRedirectHandler(BaseHandler):
|
||||||
"""Redirect anything outside a prefix inside.
|
"""Redirect anything outside a prefix inside.
|
||||||
|
|
||||||
Redirects /foo to /prefix/foo, etc.
|
Redirects /foo to /prefix/foo, etc.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -437,22 +463,27 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
|
|
||||||
|
|
||||||
class UserSpawnHandler(BaseHandler):
|
class UserSpawnHandler(BaseHandler):
|
||||||
"""Requests to /user/name handled by the Hub
|
"""Redirect requests to /user/name/* handled by the Hub.
|
||||||
should result in spawning the single-user server and
|
|
||||||
being redirected to the original.
|
If logged in, spawn a single-user server and redirect request.
|
||||||
|
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
||||||
|
redirect her to /user/alice/notebooks/mynotebook.ipynb, which should
|
||||||
|
in turn call this function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def get(self, name):
|
def get(self, name, user_path):
|
||||||
current_user = self.get_current_user()
|
current_user = self.get_current_user()
|
||||||
if current_user and current_user.name == name:
|
if current_user and current_user.name == name:
|
||||||
# logged in, spawn the server
|
# logged in as correct user, spawn the server
|
||||||
if current_user.spawner:
|
if current_user.spawner:
|
||||||
if current_user.spawn_pending:
|
if current_user.spawn_pending:
|
||||||
# spawn has started, but not finished
|
# spawn has started, but not finished
|
||||||
|
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||||
html = self.render_template("spawn_pending.html", user=current_user)
|
html = self.render_template("spawn_pending.html", user=current_user)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
return
|
return
|
||||||
|
|
||||||
# spawn has supposedly finished, check on the status
|
# spawn has supposedly finished, check on the status
|
||||||
status = yield current_user.spawner.poll()
|
status = yield current_user.spawner.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -465,25 +496,38 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
self.set_login_cookie(current_user)
|
self.set_login_cookie(current_user)
|
||||||
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||||
target = url_path_join(self.base_url, without_prefix)
|
target = url_path_join(self.base_url, without_prefix)
|
||||||
|
if self.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
|
||||||
|
self.statsd.incr('redirects.user_to_user', 1)
|
||||||
|
target = url_path_join(current_user.url, user_path or '')
|
||||||
self.redirect(target)
|
self.redirect(target)
|
||||||
else:
|
else:
|
||||||
# not logged in to the right user,
|
# not logged in, clear any cookies and reload
|
||||||
# clear any cookies and reload (will redirect to login)
|
self.statsd.incr('redirects.user_to_login', 1)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
self.redirect(url_concat(
|
self.redirect(url_concat(
|
||||||
self.settings['login_url'],
|
self.settings['login_url'],
|
||||||
{'next': self.request.uri,
|
{'next': self.request.uri},
|
||||||
}))
|
))
|
||||||
|
|
||||||
|
|
||||||
class CSPReportHandler(BaseHandler):
|
class CSPReportHandler(BaseHandler):
|
||||||
'''Accepts a content security policy violation report'''
|
'''Accepts a content security policy violation report'''
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
def post(self):
|
def post(self):
|
||||||
'''Log a content security policy violation report'''
|
'''Log a content security policy violation report'''
|
||||||
self.log.warn("Content security violation: %s",
|
self.log.warning(
|
||||||
self.request.body.decode('utf8', 'replace'))
|
"Content security violation: %s",
|
||||||
|
self.request.body.decode('utf8', 'replace')
|
||||||
|
)
|
||||||
|
# Report it to statsd as well
|
||||||
|
self.statsd.incr('csp_report')
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||||
(r'/security/csp-report', CSPReportHandler),
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
]
|
]
|
||||||
|
@@ -15,10 +15,11 @@ class LogoutHandler(BaseHandler):
|
|||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user:
|
if user:
|
||||||
self.log.info("User logged out: %s", user.name)
|
self.log.info("User logged out: %s", user.name)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
for name in user.other_user_cookies:
|
for name in user.other_user_cookies:
|
||||||
self.clear_login_cookie(name)
|
self.clear_login_cookie(name)
|
||||||
user.other_user_cookies = set([])
|
user.other_user_cookies = set([])
|
||||||
|
self.statsd.incr('logout')
|
||||||
self.redirect(self.hub.server.base_url, permanent=False)
|
self.redirect(self.hub.server.base_url, permanent=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class LoginHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
self.statsd.incr('login.request')
|
||||||
next_url = self.get_argument('next', '')
|
next_url = self.get_argument('next', '')
|
||||||
if not next_url.startswith('/'):
|
if not next_url.startswith('/'):
|
||||||
# disallow non-absolute next URLs (e.g. full URLs)
|
# disallow non-absolute next URLs (e.g. full URLs)
|
||||||
@@ -43,7 +45,7 @@ class LoginHandler(BaseHandler):
|
|||||||
if user:
|
if user:
|
||||||
if not next_url:
|
if not next_url:
|
||||||
if user.running:
|
if user.running:
|
||||||
next_url = user.server.base_url
|
next_url = user.url
|
||||||
else:
|
else:
|
||||||
next_url = self.hub.server.base_url
|
next_url = self.hub.server.base_url
|
||||||
# set new login cookie
|
# set new login cookie
|
||||||
@@ -61,8 +63,13 @@ class LoginHandler(BaseHandler):
|
|||||||
for arg in self.request.arguments:
|
for arg in self.request.arguments:
|
||||||
data[arg] = self.get_argument(arg)
|
data[arg] = self.get_argument(arg)
|
||||||
|
|
||||||
|
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||||
username = yield self.authenticate(data)
|
username = yield self.authenticate(data)
|
||||||
|
auth_timer.stop(send=False)
|
||||||
|
|
||||||
if username:
|
if username:
|
||||||
|
self.statsd.incr('login.success')
|
||||||
|
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||||
user = self.user_from_username(username)
|
user = self.user_from_username(username)
|
||||||
already_running = False
|
already_running = False
|
||||||
if user.spawner:
|
if user.spawner:
|
||||||
@@ -78,7 +85,9 @@ class LoginHandler(BaseHandler):
|
|||||||
self.redirect(next_url)
|
self.redirect(next_url)
|
||||||
self.log.info("User logged in: %s", username)
|
self.log.info("User logged in: %s", username)
|
||||||
else:
|
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(
|
html = self._render(
|
||||||
login_error='Invalid username or password',
|
login_error='Invalid username or password',
|
||||||
username=username,
|
username=username,
|
||||||
|
@@ -3,30 +3,33 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from http.client import responses
|
||||||
|
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
from tornado import web, gen
|
from tornado import web, gen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only, url_path_join
|
from ..utils import admin_only, url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
from .login import LoginHandler
|
|
||||||
|
|
||||||
|
|
||||||
class RootHandler(BaseHandler):
|
class RootHandler(BaseHandler):
|
||||||
"""Render the Hub root page.
|
"""Render the Hub root page.
|
||||||
|
|
||||||
If logged in, redirects to:
|
If logged in, redirects to:
|
||||||
|
|
||||||
- single-user server if running
|
- single-user server if running
|
||||||
- hub home, otherwise
|
- hub home, otherwise
|
||||||
|
|
||||||
Otherwise, renders login page.
|
Otherwise, renders login page.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user:
|
if user:
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.debug("User is running: %s", url)
|
self.log.debug("User is running: %s", url)
|
||||||
|
self.set_login_cookie(user) # set cookie
|
||||||
else:
|
else:
|
||||||
url = url_path_join(self.hub.server.base_url, 'home')
|
url = url_path_join(self.hub.server.base_url, 'home')
|
||||||
self.log.debug("User is not running: %s", url)
|
self.log.debug("User is not running: %s", url)
|
||||||
@@ -40,18 +43,23 @@ class HomeHandler(BaseHandler):
|
|||||||
"""Render the user's home page."""
|
"""Render the user's home page."""
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
|
@gen.coroutine
|
||||||
def get(self):
|
def get(self):
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user.running:
|
||||||
|
# trigger poll_and_notify event in case of a server that died
|
||||||
|
yield user.spawner.poll_and_notify()
|
||||||
html = self.render_template('home.html',
|
html = self.render_template('home.html',
|
||||||
user=self.get_current_user(),
|
user=user,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
|
|
||||||
class SpawnHandler(BaseHandler):
|
class SpawnHandler(BaseHandler):
|
||||||
"""Handle spawning of single-user servers via form.
|
"""Handle spawning of single-user servers via form.
|
||||||
|
|
||||||
GET renders the form, POST handles form submission.
|
GET renders the form, POST handles form submission.
|
||||||
|
|
||||||
Only enabled when Spawner.options_form is defined.
|
Only enabled when Spawner.options_form is defined.
|
||||||
"""
|
"""
|
||||||
def _render_form(self, message=''):
|
def _render_form(self, message=''):
|
||||||
@@ -67,7 +75,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
"""GET renders form for spawning with user-specified options"""
|
"""GET renders form for spawning with user-specified options"""
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.debug("User is running: %s", url)
|
self.log.debug("User is running: %s", url)
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
@@ -75,16 +83,15 @@ class SpawnHandler(BaseHandler):
|
|||||||
self.finish(self._render_form())
|
self.finish(self._render_form())
|
||||||
else:
|
else:
|
||||||
# not running, no form. Trigger spawn.
|
# not running, no form. Trigger spawn.
|
||||||
url = url_path_join(self.base_url, 'user', user.name)
|
self.redirect(user.url)
|
||||||
self.redirect(url)
|
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def post(self):
|
def post(self):
|
||||||
"""POST spawns with user-specified options"""
|
"""POST spawns with user-specified options"""
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user.running:
|
if user.running:
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.log.warning("User is already running: %s", url)
|
self.log.warning("User is already running: %s", url)
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
@@ -93,15 +100,15 @@ class SpawnHandler(BaseHandler):
|
|||||||
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
||||||
for key, byte_list in self.request.files.items():
|
for key, byte_list in self.request.files.items():
|
||||||
form_options["%s_file"%key] = byte_list
|
form_options["%s_file"%key] = byte_list
|
||||||
options = user.spawner.options_from_form(form_options)
|
|
||||||
try:
|
try:
|
||||||
|
options = user.spawner.options_from_form(form_options)
|
||||||
yield self.spawn_single_user(user, options=options)
|
yield self.spawn_single_user(user, options=options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
||||||
self.finish(self._render_form(str(e)))
|
self.finish(self._render_form(str(e)))
|
||||||
return
|
return
|
||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
url = user.server.base_url
|
url = user.url
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
|
|
||||||
class AdminHandler(BaseHandler):
|
class AdminHandler(BaseHandler):
|
||||||
@@ -122,14 +129,14 @@ class AdminHandler(BaseHandler):
|
|||||||
}
|
}
|
||||||
sorts = self.get_arguments('sort') or default_sort
|
sorts = self.get_arguments('sort') or default_sort
|
||||||
orders = self.get_arguments('order')
|
orders = self.get_arguments('order')
|
||||||
|
|
||||||
for bad in set(sorts).difference(available):
|
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)
|
sorts.remove(bad)
|
||||||
for bad in set(orders).difference({'asc', 'desc'}):
|
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)
|
orders.remove(bad)
|
||||||
|
|
||||||
# add default sort as secondary
|
# add default sort as secondary
|
||||||
for s in default_sort:
|
for s in default_sort:
|
||||||
if s not in sorts:
|
if s not in sorts:
|
||||||
@@ -139,17 +146,17 @@ class AdminHandler(BaseHandler):
|
|||||||
orders.append(default_order[col])
|
orders.append(default_order[col])
|
||||||
else:
|
else:
|
||||||
orders = orders[:len(sorts)]
|
orders = orders[:len(sorts)]
|
||||||
|
|
||||||
# this could be one incomprehensible nested list comprehension
|
# this could be one incomprehensible nested list comprehension
|
||||||
# get User columns
|
# get User columns
|
||||||
cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
|
cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
|
||||||
# get User.col.desc() order objects
|
# get User.col.desc() order objects
|
||||||
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
||||||
|
|
||||||
users = self.db.query(orm.User).order_by(*ordered)
|
users = self.db.query(orm.User).order_by(*ordered)
|
||||||
users = [ self._user_from_orm(u) for u in users ]
|
users = [ self._user_from_orm(u) for u in users ]
|
||||||
running = [ u for u in users if u.running ]
|
running = [ u for u in users if u.running ]
|
||||||
|
|
||||||
html = self.render_template('admin.html',
|
html = self.render_template('admin.html',
|
||||||
user=self.get_current_user(),
|
user=self.get_current_user(),
|
||||||
admin_access=self.settings.get('admin_access', False),
|
admin_access=self.settings.get('admin_access', False),
|
||||||
@@ -160,9 +167,43 @@ class AdminHandler(BaseHandler):
|
|||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyErrorHandler(BaseHandler):
|
||||||
|
"""Handler for rendering proxy error pages"""
|
||||||
|
|
||||||
|
def get(self, status_code_s):
|
||||||
|
status_code = int(status_code_s)
|
||||||
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||||
|
# build template namespace
|
||||||
|
|
||||||
|
hub_home = url_path_join(self.hub.server.base_url, 'home')
|
||||||
|
message_html = ''
|
||||||
|
if status_code == 503:
|
||||||
|
message_html = ' '.join([
|
||||||
|
"Your server appears to be down.",
|
||||||
|
"Try restarting it <a href='%s'>from the hub</a>" % hub_home
|
||||||
|
])
|
||||||
|
ns = dict(
|
||||||
|
status_code=status_code,
|
||||||
|
status_message=status_message,
|
||||||
|
message_html=message_html,
|
||||||
|
logo_url=hub_home,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.set_header('Content-Type', 'text/html')
|
||||||
|
# render the template
|
||||||
|
try:
|
||||||
|
html = self.render_template('%s.html' % status_code, **ns)
|
||||||
|
except TemplateNotFound:
|
||||||
|
self.log.debug("No template for %d", status_code)
|
||||||
|
html = self.render_template('error.html', **ns)
|
||||||
|
|
||||||
|
self.write(html)
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/', RootHandler),
|
(r'/', RootHandler),
|
||||||
(r'/home', HomeHandler),
|
(r'/home', HomeHandler),
|
||||||
(r'/admin', AdminHandler),
|
(r'/admin', AdminHandler),
|
||||||
(r'/spawn', SpawnHandler),
|
(r'/spawn', SpawnHandler),
|
||||||
|
(r'/error/(\d+)', ProxyErrorHandler),
|
||||||
]
|
]
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
import os
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
class CacheControlStaticFilesHandler(StaticFileHandler):
|
class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||||
@@ -14,4 +15,14 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
|
|||||||
def set_extra_headers(self, path):
|
def set_extra_headers(self, path):
|
||||||
if "v" not in self.request.arguments:
|
if "v" not in self.request.arguments:
|
||||||
self.add_header("Cache-Control", "no-cache")
|
self.add_header("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
class LogoHandler(StaticFileHandler):
|
||||||
|
"""A singular handler for serving the logo."""
|
||||||
|
def get(self):
|
||||||
|
return super().get('')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_absolute_path(cls, root, path):
|
||||||
|
"""We only serve one file, ignore relative path"""
|
||||||
|
return os.path.abspath(root)
|
||||||
|
|
||||||
|
@@ -4,15 +4,13 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import errno
|
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||||
|
|
||||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
from sqlalchemy.types import TypeDecorator, TEXT
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
inspect,
|
inspect,
|
||||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||||
@@ -26,7 +24,7 @@ from sqlalchemy import create_engine
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||||
new_token, hash_token, compare_token, localhost,
|
new_token, hash_token, compare_token, can_connect,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +37,7 @@ class JSONDict(TypeDecorator):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
impl = VARCHAR
|
impl = TEXT
|
||||||
|
|
||||||
def process_bind_param(self, value, dialect):
|
def process_bind_param(self, value, dialect):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
@@ -59,26 +57,26 @@ Base.log = app_log
|
|||||||
|
|
||||||
class Server(Base):
|
class Server(Base):
|
||||||
"""The basic state of a server
|
"""The basic state of a server
|
||||||
|
|
||||||
connection and cookie info
|
connection and cookie info
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'servers'
|
__tablename__ = 'servers'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
proto = Column(Unicode, default='http')
|
proto = Column(Unicode(15), default='http')
|
||||||
ip = Column(Unicode, default='')
|
ip = Column(Unicode(255), default='') # could also be a DNS name
|
||||||
port = Column(Integer, default=random_port)
|
port = Column(Integer, default=random_port)
|
||||||
base_url = Column(Unicode, default='/')
|
base_url = Column(Unicode(255), default='/')
|
||||||
cookie_name = Column(Unicode, default='cookie')
|
cookie_name = Column(Unicode(255), default='cookie')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
ip = self.ip
|
ip = self.ip
|
||||||
if ip in {'', '0.0.0.0'}:
|
if ip in {'', '0.0.0.0'}:
|
||||||
# when listening on all interfaces, connect to localhost
|
# when listening on all interfaces, connect to localhost
|
||||||
ip = localhost()
|
ip = '127.0.0.1'
|
||||||
return "{proto}://{ip}:{port}".format(
|
return "{proto}://{ip}:{port}".format(
|
||||||
proto=self.proto,
|
proto=self.proto,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
@@ -91,52 +89,34 @@ class Server(Base):
|
|||||||
host=self.host,
|
host=self.host,
|
||||||
uri=self.base_url,
|
uri=self.base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bind_url(self):
|
def bind_url(self):
|
||||||
"""representation of URL used for binding
|
"""representation of URL used for binding
|
||||||
|
|
||||||
Never used in APIs, only logging,
|
Never used in APIs, only logging,
|
||||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||||
"""
|
"""
|
||||||
if self.ip in {'', '0.0.0.0'}:
|
if self.ip in {'', '0.0.0.0'}:
|
||||||
return self.url.replace('localhost', self.ip or '*', 1)
|
return self.url.replace('127.0.0.1', self.ip or '*', 1)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_up(self, timeout=10, http=False):
|
def wait_up(self, timeout=10, http=False):
|
||||||
"""Wait for this server to come up"""
|
"""Wait for this server to come up"""
|
||||||
if http:
|
if http:
|
||||||
yield wait_for_http_server(self.url, timeout=timeout)
|
yield wait_for_http_server(self.url, timeout=timeout)
|
||||||
else:
|
else:
|
||||||
yield wait_for_server(self.ip or localhost(), self.port, timeout=timeout)
|
yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
|
||||||
|
|
||||||
def is_up(self):
|
def is_up(self):
|
||||||
"""Is the server accepting connections?"""
|
"""Is the server accepting connections?"""
|
||||||
try:
|
return can_connect(self.ip or '127.0.0.1', self.port)
|
||||||
socket.create_connection((self.ip or localhost(), self.port))
|
|
||||||
except socket.error as e:
|
|
||||||
if e.errno == errno.ENETUNREACH:
|
|
||||||
try:
|
|
||||||
socket.create_connection((self.ip or '127.0.0.1', self.port))
|
|
||||||
except socket.error as e:
|
|
||||||
if e.errno == errno.ECONNREFUSED:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
elif e.errno == errno.ECONNREFUSED:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Proxy(Base):
|
class Proxy(Base):
|
||||||
"""A configurable-http-proxy instance.
|
"""A configurable-http-proxy instance.
|
||||||
|
|
||||||
A proxy consists of the API server info and the public-facing server info,
|
A proxy consists of the API server info and the public-facing server info,
|
||||||
plus an auth token for configuring the proxy table.
|
plus an auth token for configuring the proxy table.
|
||||||
"""
|
"""
|
||||||
@@ -147,7 +127,7 @@ class Proxy(Base):
|
|||||||
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
||||||
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
|
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.public_server:
|
if self.public_server:
|
||||||
return "<%s %s:%s>" % (
|
return "<%s %s:%s>" % (
|
||||||
@@ -155,7 +135,7 @@ class Proxy(Base):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||||
|
|
||||||
def api_request(self, path, method='GET', body=None, client=None):
|
def api_request(self, path, method='GET', body=None, client=None):
|
||||||
"""Make an authenticated API request of the proxy"""
|
"""Make an authenticated API request of the proxy"""
|
||||||
client = client or AsyncHTTPClient()
|
client = client or AsyncHTTPClient()
|
||||||
@@ -176,10 +156,14 @@ class Proxy(Base):
|
|||||||
def add_user(self, user, client=None):
|
def add_user(self, user, client=None):
|
||||||
"""Add a user's server to the proxy table."""
|
"""Add a user's server to the proxy table."""
|
||||||
self.log.info("Adding user %s to proxy %s => %s",
|
self.log.info("Adding user %s to proxy %s => %s",
|
||||||
user.name, user.server.base_url, user.server.host,
|
user.name, user.proxy_path, user.server.host,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield self.api_request(user.server.base_url,
|
if user.spawn_pending:
|
||||||
|
raise RuntimeError(
|
||||||
|
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
|
||||||
|
|
||||||
|
yield self.api_request(user.proxy_path,
|
||||||
method='POST',
|
method='POST',
|
||||||
body=dict(
|
body=dict(
|
||||||
target=user.server.host,
|
target=user.server.host,
|
||||||
@@ -187,30 +171,15 @@ class Proxy(Base):
|
|||||||
),
|
),
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def delete_user(self, user, client=None):
|
def delete_user(self, user, client=None):
|
||||||
"""Remove a user's server to the proxy table."""
|
"""Remove a user's server to the proxy table."""
|
||||||
self.log.info("Removing user %s from proxy", user.name)
|
self.log.info("Removing user %s from proxy", user.name)
|
||||||
yield self.api_request(user.server.base_url,
|
yield self.api_request(user.proxy_path,
|
||||||
method='DELETE',
|
method='DELETE',
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@gen.coroutine
|
|
||||||
def add_all_users(self):
|
|
||||||
"""Update the proxy table from the database.
|
|
||||||
|
|
||||||
Used when loading up a new proxy.
|
|
||||||
"""
|
|
||||||
db = inspect(self).session
|
|
||||||
futures = []
|
|
||||||
for user in db.query(User):
|
|
||||||
if (user.server):
|
|
||||||
futures.append(self.add_user(user))
|
|
||||||
# wait after submitting them all
|
|
||||||
for f in futures:
|
|
||||||
yield f
|
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def get_routes(self, client=None):
|
def get_routes(self, client=None):
|
||||||
@@ -219,17 +188,42 @@ class Proxy(Base):
|
|||||||
return json.loads(resp.body.decode('utf8', 'replace'))
|
return json.loads(resp.body.decode('utf8', 'replace'))
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def check_routes(self, routes=None):
|
def add_all_users(self, user_dict):
|
||||||
"""Check that all users are properly"""
|
"""Update the proxy table from the database.
|
||||||
|
|
||||||
|
Used when loading up a new proxy.
|
||||||
|
"""
|
||||||
|
db = inspect(self).session
|
||||||
|
futures = []
|
||||||
|
for orm_user in db.query(User):
|
||||||
|
user = user_dict[orm_user]
|
||||||
|
if user.running:
|
||||||
|
futures.append(self.add_user(user))
|
||||||
|
# wait after submitting them all
|
||||||
|
for f in futures:
|
||||||
|
yield f
|
||||||
|
|
||||||
|
@gen.coroutine
|
||||||
|
def check_routes(self, user_dict, routes=None):
|
||||||
|
"""Check that all users are properly routed on the proxy"""
|
||||||
if not routes:
|
if not routes:
|
||||||
routes = yield self.get_routes()
|
routes = yield self.get_routes()
|
||||||
|
|
||||||
have_routes = { r['user'] for r in routes.values() if 'user' in r }
|
have_routes = { r['user'] for r in routes.values() if 'user' in r }
|
||||||
futures = []
|
futures = []
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
for user in db.query(User).filter(User.server != None):
|
for orm_user in db.query(User).filter(User.server != None):
|
||||||
|
user = user_dict[orm_user]
|
||||||
|
if not user.running:
|
||||||
|
# Don't add users to the proxy that haven't finished starting
|
||||||
|
continue
|
||||||
|
if user.server is None:
|
||||||
|
# This should never be True, but seems to be on rare occasion.
|
||||||
|
# catch filter bug, either in sqlalchemy or my understanding of its behavior
|
||||||
|
self.log.error("User %s has no server, but wasn't filtered out.", user)
|
||||||
|
continue
|
||||||
if user.name not in have_routes:
|
if user.name not in have_routes:
|
||||||
self.log.warn("Adding missing route for %s", user.name)
|
self.log.warning("Adding missing route for %s (%s)", user.name, user.server)
|
||||||
futures.append(self.add_user(user))
|
futures.append(self.add_user(user))
|
||||||
for f in futures:
|
for f in futures:
|
||||||
yield f
|
yield f
|
||||||
@@ -238,9 +232,9 @@ class Proxy(Base):
|
|||||||
|
|
||||||
class Hub(Base):
|
class Hub(Base):
|
||||||
"""Bring it all together at the hub.
|
"""Bring it all together at the hub.
|
||||||
|
|
||||||
The Hub is a server, plus its API path suffix
|
The Hub is a server, plus its API path suffix
|
||||||
|
|
||||||
the api_url is the full URL plus the api_path suffix on the end
|
the api_url is the full URL plus the api_path suffix on the end
|
||||||
of the server base_url.
|
of the server base_url.
|
||||||
"""
|
"""
|
||||||
@@ -248,12 +242,13 @@ class Hub(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||||
|
host = ''
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
"""return the full API url (with proto://host...)"""
|
"""return the full API url (with proto://host...)"""
|
||||||
return url_path_join(self.server.url, 'api')
|
return url_path_join(self.server.url, 'api')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.server:
|
if self.server:
|
||||||
return "<%s %s:%s>" % (
|
return "<%s %s:%s>" % (
|
||||||
@@ -265,31 +260,31 @@ class Hub(Base):
|
|||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""The User table
|
"""The User table
|
||||||
|
|
||||||
Each user has a single server,
|
Each user has a single server,
|
||||||
and multiple tokens used for authorization.
|
and multiple tokens used for authorization.
|
||||||
|
|
||||||
API tokens grant access to the Hub's REST API.
|
API tokens grant access to the Hub's REST API.
|
||||||
These are used by single-user servers to authenticate requests,
|
These are used by single-user servers to authenticate requests,
|
||||||
and external services to manipulate the Hub.
|
and external services to manipulate the Hub.
|
||||||
|
|
||||||
Cookies are set with a single ID.
|
Cookies are set with a single ID.
|
||||||
Resetting the Cookie ID invalidates all cookies, forcing user to login again.
|
Resetting the Cookie ID invalidates all cookies, forcing user to login again.
|
||||||
|
|
||||||
A `state` column contains a JSON dict,
|
A `state` column contains a JSON dict,
|
||||||
used for restoring state of a Spawner.
|
used for restoring state of a Spawner.
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(Unicode)
|
name = Column(Unicode(1023))
|
||||||
# should we allow multiple servers per user?
|
# should we allow multiple servers per user?
|
||||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||||
admin = Column(Boolean, default=False)
|
admin = Column(Boolean, default=False)
|
||||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
api_tokens = relationship("APIToken", backref="user")
|
api_tokens = relationship("APIToken", backref="user")
|
||||||
cookie_id = Column(Unicode, default=new_token)
|
cookie_id = Column(Unicode(1023), default=new_token)
|
||||||
state = Column(JSONDict)
|
state = Column(JSONDict)
|
||||||
|
|
||||||
other_user_cookies = set([])
|
other_user_cookies = set([])
|
||||||
@@ -307,12 +302,22 @@ class User(Base):
|
|||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_api_token(self):
|
def new_api_token(self, token=None):
|
||||||
"""Create a new API token"""
|
"""Create a new API token
|
||||||
|
|
||||||
|
If `token` is given, load that token.
|
||||||
|
"""
|
||||||
assert self.id is not None
|
assert self.id is not None
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
token = new_token()
|
if token is None:
|
||||||
|
token = new_token()
|
||||||
|
else:
|
||||||
|
if len(token) < 8:
|
||||||
|
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
|
||||||
|
found = APIToken.find(db, token)
|
||||||
|
if found:
|
||||||
|
raise ValueError("Collision on token: %s..." % token[:4])
|
||||||
orm_token = APIToken(user_id=self.id)
|
orm_token = APIToken(user_id=self.id)
|
||||||
orm_token.token = token
|
orm_token.token = token
|
||||||
db.add(orm_token)
|
db.add(orm_token)
|
||||||
@@ -330,29 +335,29 @@ class User(Base):
|
|||||||
class APIToken(Base):
|
class APIToken(Base):
|
||||||
"""An API token"""
|
"""An API token"""
|
||||||
__tablename__ = 'api_tokens'
|
__tablename__ = 'api_tokens'
|
||||||
|
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def user_id(cls):
|
def user_id(cls):
|
||||||
return Column(Integer, ForeignKey('users.id'))
|
return Column(Integer, ForeignKey('users.id'))
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
hashed = Column(Unicode)
|
hashed = Column(Unicode(1023))
|
||||||
prefix = Column(Unicode)
|
prefix = Column(Unicode(1023))
|
||||||
prefix_length = 4
|
prefix_length = 4
|
||||||
algorithm = "sha512"
|
algorithm = "sha512"
|
||||||
rounds = 16384
|
rounds = 16384
|
||||||
salt_bytes = 8
|
salt_bytes = 8
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def token(self):
|
def token(self):
|
||||||
raise AttributeError("token is write-only")
|
raise AttributeError("token is write-only")
|
||||||
|
|
||||||
@token.setter
|
@token.setter
|
||||||
def token(self, token):
|
def token(self, token):
|
||||||
"""Store the hashed value and prefix for a token"""
|
"""Store the hashed value and prefix for a token"""
|
||||||
self.prefix = token[:self.prefix_length]
|
self.prefix = token[:self.prefix_length]
|
||||||
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{cls}('{pre}...', user='{u}')>".format(
|
return "<{cls}('{pre}...', user='{u}')>".format(
|
||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
@@ -373,7 +378,7 @@ class APIToken(Base):
|
|||||||
for orm_token in prefix_match:
|
for orm_token in prefix_match:
|
||||||
if orm_token.match(token):
|
if orm_token.match(token):
|
||||||
return orm_token
|
return orm_token
|
||||||
|
|
||||||
def match(self, token):
|
def match(self, token):
|
||||||
"""Is this my token?"""
|
"""Is this my token?"""
|
||||||
return compare_token(self.hashed, token)
|
return compare_token(self.hashed, token)
|
||||||
@@ -383,6 +388,8 @@ def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
|||||||
"""Create a new session at url"""
|
"""Create a new session at url"""
|
||||||
if url.startswith('sqlite'):
|
if url.startswith('sqlite'):
|
||||||
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
||||||
|
elif url.startswith('mysql'):
|
||||||
|
kwargs.setdefault('pool_recycle', 60)
|
||||||
|
|
||||||
if url.endswith(':memory:'):
|
if url.endswith(':memory:'):
|
||||||
# If we're using an in-memory database, ensure that only one connection
|
# If we're using an in-memory database, ensure that only one connection
|
||||||
|
@@ -10,11 +10,12 @@ import pwd
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import grp
|
import grp
|
||||||
|
import warnings
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import mkdtemp
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import PeriodicCallback
|
||||||
|
|
||||||
from traitlets.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from traitlets import (
|
from traitlets import (
|
||||||
@@ -22,7 +23,7 @@ from traitlets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .traitlets import Command
|
from .traitlets import Command
|
||||||
from .utils import random_port, localhost
|
from .utils import random_port
|
||||||
|
|
||||||
class Spawner(LoggingConfigurable):
|
class Spawner(LoggingConfigurable):
|
||||||
"""Base class for spawning single-user notebook servers.
|
"""Base class for spawning single-user notebook servers.
|
||||||
@@ -41,39 +42,38 @@ class Spawner(LoggingConfigurable):
|
|||||||
hub = Any()
|
hub = Any()
|
||||||
authenticator = Any()
|
authenticator = Any()
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
ip = Unicode(localhost(), config=True,
|
ip = Unicode('127.0.0.1',
|
||||||
help="The IP address (or hostname) the single-user server should listen on"
|
help="The IP address (or hostname) the single-user server should listen on"
|
||||||
)
|
).tag(config=True)
|
||||||
start_timeout = Integer(60, config=True,
|
start_timeout = Integer(60,
|
||||||
help="""Timeout (in seconds) before giving up on the spawner.
|
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.
|
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.
|
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.
|
start should return when the server process is started and its location is known.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
http_timeout = Integer(
|
http_timeout = Integer(30,
|
||||||
30, config=True,
|
|
||||||
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
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
|
Once a server has successfully been spawned, this is the amount of time
|
||||||
we wait before assuming that the server is unable to accept
|
we wait before assuming that the server is unable to accept
|
||||||
connections.
|
connections.
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
poll_interval = Integer(30, config=True,
|
poll_interval = Integer(30,
|
||||||
help="""Interval (in seconds) on which to poll the spawner."""
|
help="""Interval (in seconds) on which to poll the spawner."""
|
||||||
)
|
).tag(config=True)
|
||||||
_callbacks = List()
|
_callbacks = List()
|
||||||
_poll_callback = Any()
|
_poll_callback = Any()
|
||||||
|
|
||||||
debug = Bool(False, config=True,
|
debug = Bool(False,
|
||||||
help="Enable debug-logging of the single-user server"
|
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.
|
An HTML form for options a user can specify on launching their server.
|
||||||
The surrounding `<form>` element and the submit button are already provided.
|
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="A">The letter A</option>
|
||||||
<option value="B">The letter B</option>
|
<option value="B">The letter B</option>
|
||||||
</select>
|
</select>
|
||||||
""")
|
""").tag(config=True)
|
||||||
|
|
||||||
def options_from_form(self, form_data):
|
def options_from_form(self, form_data):
|
||||||
"""Interpret HTTP form data
|
"""Interpret HTTP form data
|
||||||
@@ -113,32 +113,58 @@ class Spawner(LoggingConfigurable):
|
|||||||
'VIRTUAL_ENV',
|
'VIRTUAL_ENV',
|
||||||
'LANG',
|
'LANG',
|
||||||
'LC_ALL',
|
'LC_ALL',
|
||||||
], config=True,
|
],
|
||||||
help="Whitelist of environment variables for the subprocess to inherit"
|
help="Whitelist of environment variables for the subprocess to inherit"
|
||||||
)
|
).tag(config=True)
|
||||||
env = Dict()
|
env = Dict(help="""Deprecated: use Spawner.get_env or Spawner.environment
|
||||||
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
|
|
||||||
|
|
||||||
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."""
|
help="""The command used for starting notebooks."""
|
||||||
)
|
).tag(config=True)
|
||||||
args = List(Unicode, config=True,
|
args = List(Unicode(),
|
||||||
help="""Extra arguments to be passed to the single-user server"""
|
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
|
help="""The notebook directory for the single-user server
|
||||||
|
|
||||||
`~` will be expanded to the user's home directory
|
`~` will be expanded to the user's home directory
|
||||||
`%U` will be expanded to the user's username
|
`%U` will be expanded to the user's username
|
||||||
"""
|
"""
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
|
default_url = Unicode('',
|
||||||
|
help="""The default URL for the single-user server.
|
||||||
|
|
||||||
|
Can be used in conjunction with --notebook-dir=/ to enable
|
||||||
|
full filesystem traversal, while preserving user's homedir as
|
||||||
|
landing page for notebook
|
||||||
|
|
||||||
|
`%U` will be expanded to the user's username
|
||||||
|
"""
|
||||||
|
).tag(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):
|
def __init__(self, **kwargs):
|
||||||
super(Spawner, self).__init__(**kwargs)
|
super(Spawner, self).__init__(**kwargs)
|
||||||
@@ -185,12 +211,34 @@ class Spawner(LoggingConfigurable):
|
|||||||
self.api_token = ''
|
self.api_token = ''
|
||||||
|
|
||||||
def get_env(self):
|
def get_env(self):
|
||||||
"""Return the environment we should use
|
"""Return the environment dict to use for the Spawner.
|
||||||
|
|
||||||
Default returns a copy of self.env.
|
This applies things like `env_keep`, anything defined in `Spawner.environment`,
|
||||||
|
and adds the API token to the env.
|
||||||
|
|
||||||
Use this to access the env in Spawner.start to allow extension in subclasses.
|
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):
|
def get_args(self):
|
||||||
"""Return the arguments to be passed after self.cmd"""
|
"""Return the arguments to be passed after self.cmd"""
|
||||||
@@ -199,6 +247,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
'--port=%i' % self.user.server.port,
|
'--port=%i' % self.user.server.port,
|
||||||
'--cookie-name=%s' % self.user.server.cookie_name,
|
'--cookie-name=%s' % self.user.server.cookie_name,
|
||||||
'--base-url=%s' % self.user.server.base_url,
|
'--base-url=%s' % self.user.server.base_url,
|
||||||
|
'--hub-host=%s' % self.hub.host,
|
||||||
'--hub-prefix=%s' % self.hub.server.base_url,
|
'--hub-prefix=%s' % self.hub.server.base_url,
|
||||||
'--hub-api-url=%s' % self.hub.api_url,
|
'--hub-api-url=%s' % self.hub.api_url,
|
||||||
]
|
]
|
||||||
@@ -207,8 +256,14 @@ class Spawner(LoggingConfigurable):
|
|||||||
if self.notebook_dir:
|
if self.notebook_dir:
|
||||||
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
|
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
|
||||||
args.append('--notebook-dir=%s' % self.notebook_dir)
|
args.append('--notebook-dir=%s' % self.notebook_dir)
|
||||||
|
if self.default_url:
|
||||||
|
self.default_url = self.default_url.replace("%U",self.user.name)
|
||||||
|
args.append('--NotebookApp.default_url=%s' % self.default_url)
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
args.append('--debug')
|
args.append('--debug')
|
||||||
|
if self.disable_user_config:
|
||||||
|
args.append('--disable-user-config')
|
||||||
args.extend(self.args)
|
args.extend(self.args)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
@@ -280,15 +335,17 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
self.stop_polling()
|
self.stop_polling()
|
||||||
|
|
||||||
add_callback = IOLoop.current().add_callback
|
|
||||||
for callback in self._callbacks:
|
for callback in self._callbacks:
|
||||||
add_callback(callback)
|
try:
|
||||||
|
yield gen.maybe_future(callback())
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Unhandled error in poll callback for %s", self)
|
||||||
|
return status
|
||||||
|
|
||||||
death_interval = Float(0.1)
|
death_interval = Float(0.1)
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_for_death(self, timeout=10):
|
def wait_for_death(self, timeout=10):
|
||||||
"""wait for the process to die, up to timeout seconds"""
|
"""wait for the process to die, up to timeout seconds"""
|
||||||
loop = IOLoop.current()
|
|
||||||
for i in range(int(timeout / self.death_interval)):
|
for i in range(int(timeout / self.death_interval)):
|
||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -302,12 +359,13 @@ def _try_setcwd(path):
|
|||||||
try:
|
try:
|
||||||
os.chdir(path)
|
os.chdir(path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
exc = e # break exception instance out of except scope
|
||||||
print("Couldn't set CWD to %s (%s)" % (path, e), file=sys.stderr)
|
print("Couldn't set CWD to %s (%s)" % (path, e), file=sys.stderr)
|
||||||
path, _ = os.path.split(path)
|
path, _ = os.path.split(path)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
print("Couldn't set CWD at all (%s), using temp dir" % e, file=sys.stderr)
|
print("Couldn't set CWD at all (%s), using temp dir" % exc, file=sys.stderr)
|
||||||
td = TemporaryDirectory().name
|
td = mkdtemp()
|
||||||
os.chdir(td)
|
os.chdir(td)
|
||||||
|
|
||||||
|
|
||||||
@@ -342,15 +400,15 @@ class LocalProcessSpawner(Spawner):
|
|||||||
This is the default spawner for JupyterHub.
|
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"
|
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
|
||||||
)
|
).tag(config=True)
|
||||||
TERM_TIMEOUT = Integer(5, config=True,
|
TERM_TIMEOUT = Integer(5,
|
||||||
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
|
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
|
||||||
)
|
).tag(config=True)
|
||||||
KILL_TIMEOUT = Integer(5, config=True,
|
KILL_TIMEOUT = Integer(5,
|
||||||
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
||||||
)
|
).tag(config=True)
|
||||||
|
|
||||||
proc = Instance(Popen, allow_none=True)
|
proc = Instance(Popen, allow_none=True)
|
||||||
pid = Integer(0)
|
pid = Integer(0)
|
||||||
@@ -486,5 +544,5 @@ class LocalProcessSpawner(Spawner):
|
|||||||
status = yield self.poll()
|
status = yield self.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
# it all failed, zombie process
|
# it all failed, zombie process
|
||||||
self.log.warn("Process %i never died", self.pid)
|
self.log.warning("Process %i never died", self.pid)
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
"""mock utilities for testing"""
|
"""mock utilities for testing"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -13,11 +13,13 @@ from tornado import gen
|
|||||||
from tornado.concurrent import Future
|
from tornado.concurrent import Future
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
from ..spawner import LocalProcessSpawner
|
from traitlets import default
|
||||||
|
|
||||||
from ..app import JupyterHub
|
from ..app import JupyterHub
|
||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import localhost
|
from ..spawner import LocalProcessSpawner
|
||||||
|
from ..utils import url_path_join
|
||||||
|
|
||||||
from pamela import PAMError
|
from pamela import PAMError
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ class MockSpawner(LocalProcessSpawner):
|
|||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
return env
|
return env
|
||||||
|
@default('cmd')
|
||||||
def _cmd_default(self):
|
def _cmd_default(self):
|
||||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ class SlowSpawner(MockSpawner):
|
|||||||
class NeverSpawner(MockSpawner):
|
class NeverSpawner(MockSpawner):
|
||||||
"""A spawner that will never start"""
|
"""A spawner that will never start"""
|
||||||
|
|
||||||
|
@default('start_timeout')
|
||||||
def _start_timeout_default(self):
|
def _start_timeout_default(self):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ class FormSpawner(MockSpawner):
|
|||||||
|
|
||||||
|
|
||||||
class MockPAMAuthenticator(PAMAuthenticator):
|
class MockPAMAuthenticator(PAMAuthenticator):
|
||||||
|
@default('admin_users')
|
||||||
def _admin_users_default(self):
|
def _admin_users_default(self):
|
||||||
return {'admin'}
|
return {'admin'}
|
||||||
|
|
||||||
@@ -109,13 +113,23 @@ class MockHub(JupyterHub):
|
|||||||
"""Hub with various mock bits"""
|
"""Hub with various mock bits"""
|
||||||
|
|
||||||
db_file = None
|
db_file = None
|
||||||
|
confirm_no_ssl = True
|
||||||
|
|
||||||
|
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):
|
def _ip_default(self):
|
||||||
return localhost()
|
return '127.0.0.1'
|
||||||
|
|
||||||
|
@default('authenticator_class')
|
||||||
def _authenticator_class_default(self):
|
def _authenticator_class_default(self):
|
||||||
return MockPAMAuthenticator
|
return MockPAMAuthenticator
|
||||||
|
|
||||||
|
@default('spawner_class')
|
||||||
def _spawner_class_default(self):
|
def _spawner_class_default(self):
|
||||||
return MockSpawner
|
return MockSpawner
|
||||||
|
|
||||||
@@ -124,7 +138,8 @@ class MockHub(JupyterHub):
|
|||||||
|
|
||||||
def start(self, argv=None):
|
def start(self, argv=None):
|
||||||
self.db_file = NamedTemporaryFile()
|
self.db_file = NamedTemporaryFile()
|
||||||
self.db_url = 'sqlite:///' + self.db_file.name
|
self.pid_file = NamedTemporaryFile(delete=False).name
|
||||||
|
self.db_url = self.db_file.name
|
||||||
|
|
||||||
evt = threading.Event()
|
evt = threading.Event()
|
||||||
|
|
||||||
@@ -161,13 +176,33 @@ class MockHub(JupyterHub):
|
|||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
def login_user(self, name):
|
def login_user(self, name):
|
||||||
r = requests.post(self.proxy.public_server.url + 'hub/login',
|
base_url = public_url(self)
|
||||||
|
r = requests.post(base_url + 'hub/login',
|
||||||
data={
|
data={
|
||||||
'username': name,
|
'username': name,
|
||||||
'password': name,
|
'password': name,
|
||||||
},
|
},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
|
r.raise_for_status()
|
||||||
assert r.cookies
|
assert r.cookies
|
||||||
return r.cookies
|
return r.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def public_host(app):
|
||||||
|
if app.subdomain_host:
|
||||||
|
return app.subdomain_host
|
||||||
|
else:
|
||||||
|
return app.proxy.public_server.host
|
||||||
|
|
||||||
|
|
||||||
|
def public_url(app):
|
||||||
|
return public_host(app) + app.proxy.public_server.base_url
|
||||||
|
|
||||||
|
|
||||||
|
def user_url(user, app):
|
||||||
|
if app.subdomain_host:
|
||||||
|
host = user.host
|
||||||
|
else:
|
||||||
|
host = public_host(app)
|
||||||
|
return host + user.server.base_url
|
||||||
|
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ from .. import orm
|
|||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
from . import mocking
|
from . import mocking
|
||||||
|
from .mocking import public_url, user_url
|
||||||
|
|
||||||
|
|
||||||
def check_db_locks(func):
|
def check_db_locks(func):
|
||||||
@@ -41,7 +41,7 @@ def check_db_locks(func):
|
|||||||
|
|
||||||
def find_user(db, name):
|
def find_user(db, name):
|
||||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||||
|
|
||||||
def add_user(db, app=None, **kwargs):
|
def add_user(db, app=None, **kwargs):
|
||||||
orm_user = orm.User(**kwargs)
|
orm_user = orm.User(**kwargs)
|
||||||
db.add(orm_user)
|
db.add(orm_user)
|
||||||
@@ -81,17 +81,17 @@ def test_auth_api(app):
|
|||||||
db = app.db
|
db = app.db
|
||||||
r = api_request(app, 'authorizations', 'gobbledygook')
|
r = api_request(app, 'authorizations', 'gobbledygook')
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
# make a new cookie token
|
# make a new cookie token
|
||||||
user = db.query(orm.User).first()
|
user = db.query(orm.User).first()
|
||||||
api_token = user.new_api_token()
|
api_token = user.new_api_token()
|
||||||
|
|
||||||
# check success:
|
# check success:
|
||||||
r = api_request(app, 'authorizations/token', api_token)
|
r = api_request(app, 'authorizations/token', api_token)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply['name'] == user.name
|
assert reply['name'] == user.name
|
||||||
|
|
||||||
# check fail
|
# check fail
|
||||||
r = api_request(app, 'authorizations/token', api_token,
|
r = api_request(app, 'authorizations/token', api_token,
|
||||||
headers={'Authorization': 'no sir'},
|
headers={'Authorization': 'no sir'},
|
||||||
@@ -105,7 +105,7 @@ def test_auth_api(app):
|
|||||||
|
|
||||||
|
|
||||||
def test_referer_check(app, io_loop):
|
def test_referer_check(app, io_loop):
|
||||||
url = app.hub.server.url
|
url = ujoin(public_url(app), app.hub.server.base_url)
|
||||||
host = urlparse(url).netloc
|
host = urlparse(url).netloc
|
||||||
user = find_user(app.db, 'admin')
|
user = find_user(app.db, 'admin')
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -115,7 +115,7 @@ def test_referer_check(app, io_loop):
|
|||||||
# stop the admin's server so we don't mess up future tests
|
# stop the admin's server so we don't mess up future tests
|
||||||
io_loop.run_sync(lambda : app.proxy.delete_user(app_user))
|
io_loop.run_sync(lambda : app.proxy.delete_user(app_user))
|
||||||
io_loop.run_sync(app_user.stop)
|
io_loop.run_sync(app_user.stop)
|
||||||
|
|
||||||
r = api_request(app, 'users',
|
r = api_request(app, 'users',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': '',
|
'Authorization': '',
|
||||||
@@ -152,7 +152,7 @@ def test_get_users(app):
|
|||||||
db = app.db
|
db = app.db
|
||||||
r = api_request(app, 'users')
|
r = api_request(app, 'users')
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
users = sorted(r.json(), key=lambda d: d['name'])
|
users = sorted(r.json(), key=lambda d: d['name'])
|
||||||
for u in users:
|
for u in users:
|
||||||
u.pop('last_activity')
|
u.pop('last_activity')
|
||||||
@@ -230,21 +230,21 @@ def test_add_multi_user(app):
|
|||||||
reply = r.json()
|
reply = r.json()
|
||||||
r_names = [ user['name'] for user in reply ]
|
r_names = [ user['name'] for user in reply ]
|
||||||
assert names == r_names
|
assert names == r_names
|
||||||
|
|
||||||
for name in names:
|
for name in names:
|
||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert not user.admin
|
assert not user.admin
|
||||||
|
|
||||||
# try to create the same users again
|
# try to create the same users again
|
||||||
r = api_request(app, 'users', method='post',
|
r = api_request(app, 'users', method='post',
|
||||||
data=json.dumps({'usernames': names}),
|
data=json.dumps({'usernames': names}),
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
names = ['a', 'b', 'ab']
|
names = ['a', 'b', 'ab']
|
||||||
|
|
||||||
# try to create the same users again
|
# try to create the same users again
|
||||||
r = api_request(app, 'users', method='post',
|
r = api_request(app, 'users', method='post',
|
||||||
data=json.dumps({'usernames': names}),
|
data=json.dumps({'usernames': names}),
|
||||||
@@ -265,7 +265,7 @@ def test_add_multi_user_admin(app):
|
|||||||
reply = r.json()
|
reply = r.json()
|
||||||
r_names = [ user['name'] for user in reply ]
|
r_names = [ user['name'] for user in reply ]
|
||||||
assert names == r_names
|
assert names == r_names
|
||||||
|
|
||||||
for name in names:
|
for name in names:
|
||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
@@ -298,7 +298,7 @@ def test_delete_user(app):
|
|||||||
mal = add_user(db, name='mal')
|
mal = add_user(db, name='mal')
|
||||||
r = api_request(app, 'users', 'mal', method='delete')
|
r = api_request(app, 'users', 'mal', method='delete')
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def test_make_admin(app):
|
def test_make_admin(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -321,7 +321,7 @@ def test_make_admin(app):
|
|||||||
|
|
||||||
def get_app_user(app, name):
|
def get_app_user(app, name):
|
||||||
"""Get the User object from the main thread
|
"""Get the User object from the main thread
|
||||||
|
|
||||||
Needed for access to the Spawner.
|
Needed for access to the Spawner.
|
||||||
No ORM methods should be called on the result.
|
No ORM methods should be called on the result.
|
||||||
"""
|
"""
|
||||||
@@ -350,21 +350,25 @@ def test_spawn(app, io_loop):
|
|||||||
assert not app_user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
assert user.server.base_url == '/user/%s' % name
|
assert user.server.base_url == '/user/%s' % name
|
||||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
|
url = user_url(user, app)
|
||||||
|
print(url)
|
||||||
|
r = requests.get(url)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.text == user.server.base_url
|
assert r.text == user.server.base_url
|
||||||
|
|
||||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
|
r = requests.get(ujoin(url, 'args'))
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
argv = r.json()
|
argv = r.json()
|
||||||
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
|
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
|
||||||
assert expected in argv
|
assert expected in argv
|
||||||
|
if app.subdomain_host:
|
||||||
|
assert '--hub-host=%s' % app.subdomain_host in argv
|
||||||
|
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
assert 'pid' not in user.state
|
assert 'pid' not in user.state
|
||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status == 0
|
assert status == 0
|
||||||
@@ -379,18 +383,19 @@ def test_slow_spawn(app, io_loop):
|
|||||||
name = 'zoe'
|
name = 'zoe'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
r = api_request(app, 'users', name, 'server', method='post')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
|
app.tornado_settings['spawner_class'] = mocking.MockSpawner
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
app_user = get_app_user(app, name)
|
app_user = get_app_user(app, name)
|
||||||
assert app_user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert app_user.spawn_pending
|
assert app_user.spawn_pending
|
||||||
assert not app_user.stop_pending
|
assert not app_user.stop_pending
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_spawn():
|
def wait_spawn():
|
||||||
while app_user.spawn_pending:
|
while app_user.spawn_pending:
|
||||||
yield gen.sleep(0.1)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
io_loop.run_sync(wait_spawn)
|
io_loop.run_sync(wait_spawn)
|
||||||
assert not app_user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
@@ -412,13 +417,13 @@ def test_slow_spawn(app, io_loop):
|
|||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert app_user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert app_user.stop_pending
|
assert app_user.stop_pending
|
||||||
|
|
||||||
io_loop.run_sync(wait_stop)
|
io_loop.run_sync(wait_stop)
|
||||||
assert not app_user.stop_pending
|
assert not app_user.stop_pending
|
||||||
assert app_user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_never_spawn(app, io_loop):
|
def test_never_spawn(app, io_loop):
|
||||||
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
||||||
@@ -428,15 +433,16 @@ def test_never_spawn(app, io_loop):
|
|||||||
name = 'badger'
|
name = 'badger'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
r = api_request(app, 'users', name, 'server', method='post')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
|
app.tornado_settings['spawner_class'] = mocking.MockSpawner
|
||||||
app_user = get_app_user(app, name)
|
app_user = get_app_user(app, name)
|
||||||
assert app_user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert app_user.spawn_pending
|
assert app_user.spawn_pending
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_pending():
|
def wait_pending():
|
||||||
while app_user.spawn_pending:
|
while app_user.spawn_pending:
|
||||||
yield gen.sleep(0.1)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
io_loop.run_sync(wait_pending)
|
io_loop.run_sync(wait_pending)
|
||||||
assert not app_user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(app_user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
@@ -450,6 +456,76 @@ def test_get_proxy(app, io_loop):
|
|||||||
assert list(reply.keys()) == ['/']
|
assert list(reply.keys()) == ['/']
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookie(app):
|
||||||
|
db = app.db
|
||||||
|
name = 'patience'
|
||||||
|
user = add_user(db, app=app, name=name)
|
||||||
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert 'pid' in user.state
|
||||||
|
app_user = get_app_user(app, name)
|
||||||
|
|
||||||
|
cookies = app.login_user(name)
|
||||||
|
# cookie jar gives '"cookie-value"', we want 'cookie-value'
|
||||||
|
cookie = cookies[user.server.cookie_name][1:-1]
|
||||||
|
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, "nothintoseehere")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, quote(cookie, safe=''))
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert reply['name'] == name
|
||||||
|
|
||||||
|
# deprecated cookie in body:
|
||||||
|
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, data=cookie)
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert reply['name'] == name
|
||||||
|
|
||||||
|
def test_token(app):
|
||||||
|
name = 'book'
|
||||||
|
user = add_user(app.db, app=app, name=name)
|
||||||
|
token = user.new_api_token()
|
||||||
|
r = api_request(app, 'authorizations/token', token)
|
||||||
|
r.raise_for_status()
|
||||||
|
user_model = r.json()
|
||||||
|
assert user_model['name'] == name
|
||||||
|
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')
|
||||||
|
r.raise_for_status()
|
||||||
|
assert 'Access-Control-Allow-Headers' in r.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_json_body(app):
|
||||||
|
r = api_request(app, 'users', method='post', data='notjson')
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_shutdown(app):
|
def test_shutdown(app):
|
||||||
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||||
'servers': True,
|
'servers': True,
|
||||||
|
@@ -1,10 +1,17 @@
|
|||||||
"""Test the JupyterHub entry point"""
|
"""Test the JupyterHub entry point"""
|
||||||
|
|
||||||
|
import binascii
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from subprocess import check_output
|
from subprocess import check_output, Popen, PIPE
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .mocking import MockHub
|
||||||
|
from .. import orm
|
||||||
|
|
||||||
def test_help_all():
|
def test_help_all():
|
||||||
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
||||||
@@ -23,10 +30,23 @@ def test_token_app():
|
|||||||
def test_generate_config():
|
def test_generate_config():
|
||||||
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
||||||
cfg_file = tf.name
|
cfg_file = tf.name
|
||||||
|
with open(cfg_file, 'w') as f:
|
||||||
out = check_output([sys.executable, '-m', 'jupyterhub',
|
f.write("c.A = 5")
|
||||||
'--generate-config', '-f', cfg_file]
|
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||||
).decode('utf8', 'replace')
|
'--generate-config', '-f', cfg_file],
|
||||||
|
stdout=PIPE, stdin=PIPE)
|
||||||
|
out, _ = p.communicate(b'n')
|
||||||
|
out = out.decode('utf8', 'replace')
|
||||||
|
assert os.path.exists(cfg_file)
|
||||||
|
with open(cfg_file) as f:
|
||||||
|
cfg_text = f.read()
|
||||||
|
assert cfg_text == 'c.A = 5'
|
||||||
|
|
||||||
|
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||||
|
'--generate-config', '-f', cfg_file],
|
||||||
|
stdout=PIPE, stdin=PIPE)
|
||||||
|
out, _ = p.communicate(b'x\ny')
|
||||||
|
out = out.decode('utf8', 'replace')
|
||||||
assert os.path.exists(cfg_file)
|
assert os.path.exists(cfg_file)
|
||||||
with open(cfg_file) as f:
|
with open(cfg_file) as f:
|
||||||
cfg_text = f.read()
|
cfg_text = f.read()
|
||||||
@@ -34,3 +54,89 @@ def test_generate_config():
|
|||||||
assert cfg_file in out
|
assert cfg_file in out
|
||||||
assert 'Spawner.cmd' in cfg_text
|
assert 'Spawner.cmd' in cfg_text
|
||||||
assert 'Authenticator.whitelist' 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)
|
||||||
|
|
||||||
|
@@ -20,7 +20,7 @@ def test_server(db):
|
|||||||
assert server.proto == 'http'
|
assert server.proto == 'http'
|
||||||
assert isinstance(server.port, int)
|
assert isinstance(server.port, int)
|
||||||
assert isinstance(server.cookie_name, str)
|
assert isinstance(server.cookie_name, str)
|
||||||
assert server.host == 'http://localhost:%i' % server.port
|
assert server.host == 'http://127.0.0.1:%i' % server.port
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
assert server.bind_url == 'http://*:%i/' % server.port
|
assert server.bind_url == 'http://*:%i/' % server.port
|
||||||
server.ip = '127.0.0.1'
|
server.ip = '127.0.0.1'
|
||||||
@@ -93,6 +93,16 @@ def test_tokens(db):
|
|||||||
found = orm.APIToken.find(db, 'something else')
|
found = orm.APIToken.find(db, 'something else')
|
||||||
assert found is None
|
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):
|
def test_spawn_fails(db, io_loop):
|
||||||
orm_user = orm.User(name='aeofel')
|
orm_user = orm.User(name='aeofel')
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for HTML pages"""
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -8,12 +8,11 @@ from ..utils import url_path_join as ujoin
|
|||||||
from .. import orm
|
from .. import orm
|
||||||
|
|
||||||
import mock
|
import mock
|
||||||
from .mocking import FormSpawner
|
from .mocking import FormSpawner, public_url, public_host, user_url
|
||||||
from .test_api import api_request
|
from .test_api import api_request
|
||||||
|
|
||||||
|
|
||||||
def get_page(path, app, **kw):
|
def get_page(path, app, **kw):
|
||||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
base_url = ujoin(public_url(app), app.hub.server.base_url)
|
||||||
print(base_url)
|
print(base_url)
|
||||||
return requests.get(ujoin(base_url, path), **kw)
|
return requests.get(ujoin(base_url, path), **kw)
|
||||||
|
|
||||||
@@ -22,15 +21,17 @@ def test_root_no_auth(app, io_loop):
|
|||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
print(routes)
|
print(routes)
|
||||||
print(app.hub.server)
|
print(app.hub.server)
|
||||||
r = requests.get(app.proxy.public_server.host)
|
url = public_url(app)
|
||||||
|
print(url)
|
||||||
|
r = requests.get(url)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url, 'login')
|
assert r.url == ujoin(url, app.hub.server.base_url, 'login')
|
||||||
|
|
||||||
def test_root_auth(app):
|
def test_root_auth(app):
|
||||||
cookies = app.login_user('river')
|
cookies = app.login_user('river')
|
||||||
r = requests.get(app.proxy.public_server.host, cookies=cookies)
|
r = requests.get(public_url(app), cookies=cookies)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
|
assert r.url == user_url(app.users['river'], app)
|
||||||
|
|
||||||
def test_home_no_auth(app):
|
def test_home_no_auth(app):
|
||||||
r = get_page('home', app, allow_redirects=False)
|
r = get_page('home', app, allow_redirects=False)
|
||||||
@@ -62,6 +63,7 @@ def test_admin(app):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url.endswith('/admin')
|
assert r.url.endswith('/admin')
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_redirect(app, io_loop):
|
def test_spawn_redirect(app, io_loop):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
cookies = app.login_user(name)
|
cookies = app.login_user(name)
|
||||||
@@ -100,7 +102,7 @@ def test_spawn_page(app):
|
|||||||
|
|
||||||
def test_spawn_form(app, io_loop):
|
def test_spawn_form(app, io_loop):
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
base_url = ujoin(public_url(app), app.hub.server.base_url)
|
||||||
cookies = app.login_user('jones')
|
cookies = app.login_user('jones')
|
||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
@@ -121,7 +123,7 @@ def test_spawn_form(app, io_loop):
|
|||||||
|
|
||||||
def test_spawn_form_with_file(app, io_loop):
|
def test_spawn_form_with_file(app, io_loop):
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||||
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
base_url = ujoin(public_url(app), app.hub.server.base_url)
|
||||||
cookies = app.login_user('jones')
|
cookies = app.login_user('jones')
|
||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
@@ -147,3 +149,98 @@ def test_spawn_form_with_file(app, io_loop):
|
|||||||
'content_type': 'application/unknown'},
|
'content_type': 'application/unknown'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_redirect(app):
|
||||||
|
name = 'wash'
|
||||||
|
cookies = app.login_user(name)
|
||||||
|
|
||||||
|
r = get_page('/user/baduser', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(urlparse(r.url))
|
||||||
|
path = urlparse(r.url).path
|
||||||
|
assert path == '/user/%s' % name
|
||||||
|
|
||||||
|
r = get_page('/user/baduser/test.ipynb', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(urlparse(r.url))
|
||||||
|
path = urlparse(r.url).path
|
||||||
|
assert path == '/user/%s/test.ipynb' % name
|
||||||
|
|
||||||
|
r = get_page('/user/baduser/test.ipynb', app)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(urlparse(r.url))
|
||||||
|
path = urlparse(r.url).path
|
||||||
|
assert path == '/hub/login'
|
||||||
|
query = urlparse(r.url).query
|
||||||
|
assert query == urlencode({'next': '/hub/user/baduser/test.ipynb'})
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_fail(app):
|
||||||
|
name = 'wash'
|
||||||
|
base_url = public_url(app)
|
||||||
|
r = requests.post(base_url + 'hub/login',
|
||||||
|
data={
|
||||||
|
'username': name,
|
||||||
|
'password': 'wrong',
|
||||||
|
},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert not r.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_redirect(app, io_loop):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
user = app.users['river']
|
||||||
|
# no next_url, server running
|
||||||
|
io_loop.run_sync(user.spawn)
|
||||||
|
r = get_page('login', app, cookies=cookies, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert '/user/river' in r.headers['Location']
|
||||||
|
|
||||||
|
# no next_url, server not running
|
||||||
|
io_loop.run_sync(user.stop)
|
||||||
|
r = get_page('login', app, cookies=cookies, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert '/hub/' in r.headers['Location']
|
||||||
|
|
||||||
|
# next URL given, use it
|
||||||
|
r = get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert r.headers['Location'].endswith('/hub/admin')
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout(app):
|
||||||
|
name = 'wash'
|
||||||
|
cookies = app.login_user(name)
|
||||||
|
r = requests.get(public_host(app) + app.tornado_settings['logout_url'], cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
login_url = public_host(app) + app.tornado_settings['login_url']
|
||||||
|
assert r.url == login_url
|
||||||
|
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)
|
||||||
|
r = requests.get(ujoin(base_url, 'logo'))
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.headers['content-type'] == 'image/png'
|
||||||
|
r = requests.get(ujoin(base_url, 'static', 'images', 'jupyter.png'))
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.headers['content-type'] == 'image/png'
|
||||||
|
r = requests.get(ujoin(base_url, 'static', 'css', 'style.min.css'))
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.headers['content-type'] == 'text/css'
|
||||||
|
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .mocking import MockHub
|
from .mocking import MockHub
|
||||||
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
|
|||||||
'--api-port', str(proxy_port),
|
'--api-port', str(proxy_port),
|
||||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
||||||
]
|
]
|
||||||
|
if app.subdomain_host:
|
||||||
|
cmd.append('--host-routing')
|
||||||
proxy = Popen(cmd, env=env)
|
proxy = Popen(cmd, env=env)
|
||||||
def _cleanup_proxy():
|
def _cleanup_proxy():
|
||||||
if proxy.poll() is None:
|
if proxy.poll() is None:
|
||||||
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
user_path = '/user/river'
|
||||||
|
if app.subdomain_host:
|
||||||
|
domain = urlparse(app.subdomain_host).hostname
|
||||||
|
user_path = '/%s.%s' % (name, domain) + user_path
|
||||||
|
assert sorted(routes.keys()) == ['/', user_path]
|
||||||
|
|
||||||
# teardown the proxy and start a new one in the same place
|
# teardown the proxy and start a new one in the same place
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
assert sorted(routes.keys()) == ['/', user_path]
|
||||||
|
|
||||||
# teardown the proxy again, and start a new one with different auth and port
|
# teardown the proxy again, and start a new one with different auth and port
|
||||||
proxy.terminate()
|
proxy.terminate()
|
||||||
@@ -90,13 +97,16 @@ def test_external_proxy(request, io_loop):
|
|||||||
'--api-port', str(proxy_port),
|
'--api-port', str(proxy_port),
|
||||||
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
|
||||||
]
|
]
|
||||||
|
if app.subdomain_host:
|
||||||
|
cmd.append('--host-routing')
|
||||||
proxy = Popen(cmd, env=env)
|
proxy = Popen(cmd, env=env)
|
||||||
wait_for_proxy()
|
wait_for_proxy()
|
||||||
|
|
||||||
# tell the hub where the new proxy is
|
# tell the hub where the new proxy is
|
||||||
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
||||||
'port': proxy_port,
|
'port': proxy_port,
|
||||||
|
'protocol': 'http',
|
||||||
|
'ip': app.ip,
|
||||||
'auth_token': new_auth_token,
|
'auth_token': new_auth_token,
|
||||||
}))
|
}))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -113,7 +123,8 @@ def test_external_proxy(request, io_loop):
|
|||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
assert sorted(routes.keys()) == ['/', '/user/river']
|
assert sorted(routes.keys()) == ['/', user_path]
|
||||||
|
|
||||||
|
|
||||||
def test_check_routes(app, io_loop):
|
def test_check_routes(app, io_loop):
|
||||||
proxy = app.proxy
|
proxy = app.proxy
|
||||||
@@ -123,13 +134,24 @@ def test_check_routes(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
zoe = orm.User.find(app.db, 'zoe')
|
zoe = orm.User.find(app.db, 'zoe')
|
||||||
assert zoe is not None
|
assert zoe is not None
|
||||||
|
zoe = app.users[zoe]
|
||||||
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||||
assert '/user/zoe' in before
|
assert zoe.proxy_path in before
|
||||||
io_loop.run_sync(app.proxy.check_routes)
|
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
|
||||||
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
||||||
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||||
assert '/user/zoe' not in during
|
assert zoe.proxy_path not in during
|
||||||
io_loop.run_sync(app.proxy.check_routes)
|
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
|
||||||
after = sorted(io_loop.run_sync(app.proxy.get_routes))
|
after = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||||
assert '/user/zoe' in after
|
assert zoe.proxy_path in after
|
||||||
assert before == after
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_proxy_bad_req(app):
|
||||||
|
r = api_request(app, 'proxy', method='patch')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = api_request(app, 'proxy', method='patch', data='notjson')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = api_request(app, 'proxy', method='patch', data=json.dumps([]))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
@@ -4,9 +4,14 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from tornado import gen
|
||||||
|
|
||||||
from .. import spawner as spawnermod
|
from .. import spawner as spawnermod
|
||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
@@ -39,13 +44,14 @@ def new_spawner(db, **kwargs):
|
|||||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||||
|
kwargs.setdefault('poll_interval', 1)
|
||||||
return LocalProcessSpawner(db=db, **kwargs)
|
return LocalProcessSpawner(db=db, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def test_spawner(db, io_loop):
|
def test_spawner(db, io_loop):
|
||||||
spawner = new_spawner(db)
|
spawner = new_spawner(db)
|
||||||
io_loop.run_sync(spawner.start)
|
io_loop.run_sync(spawner.start)
|
||||||
assert spawner.user.server.ip == 'localhost'
|
assert spawner.user.server.ip == '127.0.0.1'
|
||||||
|
|
||||||
# wait for the process to get to the while True: loop
|
# wait for the process to get to the while True: loop
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -59,7 +65,7 @@ def test_spawner(db, io_loop):
|
|||||||
def test_single_user_spawner(db, io_loop):
|
def test_single_user_spawner(db, io_loop):
|
||||||
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
|
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
|
||||||
io_loop.run_sync(spawner.start)
|
io_loop.run_sync(spawner.start)
|
||||||
assert spawner.user.server.ip == 'localhost'
|
assert spawner.user.server.ip == '127.0.0.1'
|
||||||
# wait for http server to come up,
|
# wait for http server to come up,
|
||||||
# checking for early termination every 1s
|
# checking for early termination every 1s
|
||||||
def wait():
|
def wait():
|
||||||
@@ -110,3 +116,53 @@ def test_stop_spawner_stop_now(db, io_loop):
|
|||||||
status = io_loop.run_sync(spawner.poll)
|
status = io_loop.run_sync(spawner.poll)
|
||||||
assert status == -signal.SIGTERM
|
assert status == -signal.SIGTERM
|
||||||
|
|
||||||
|
def test_spawner_poll(db, io_loop):
|
||||||
|
first_spawner = new_spawner(db)
|
||||||
|
user = first_spawner.user
|
||||||
|
io_loop.run_sync(first_spawner.start)
|
||||||
|
proc = first_spawner.proc
|
||||||
|
status = io_loop.run_sync(first_spawner.poll)
|
||||||
|
assert status is None
|
||||||
|
user.state = first_spawner.get_state()
|
||||||
|
assert 'pid' in user.state
|
||||||
|
|
||||||
|
# create a new Spawner, loading from state of previous
|
||||||
|
spawner = new_spawner(db, user=first_spawner.user)
|
||||||
|
spawner.start_polling()
|
||||||
|
|
||||||
|
# wait for the process to get to the while True: loop
|
||||||
|
io_loop.run_sync(lambda : gen.sleep(1))
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status is None
|
||||||
|
|
||||||
|
# kill the process
|
||||||
|
proc.terminate()
|
||||||
|
for i in range(10):
|
||||||
|
if proc.poll() is None:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
assert proc.poll() is not None
|
||||||
|
|
||||||
|
io_loop.run_sync(lambda : gen.sleep(2))
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status is not None
|
||||||
|
|
||||||
|
def test_setcwd():
|
||||||
|
cwd = os.getcwd()
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
td = os.path.realpath(os.path.abspath(td))
|
||||||
|
spawnermod._try_setcwd(td)
|
||||||
|
assert os.path.samefile(os.getcwd(), td)
|
||||||
|
os.chdir(cwd)
|
||||||
|
chdir = os.chdir
|
||||||
|
temp_root = os.path.realpath(os.path.abspath(tempfile.gettempdir()))
|
||||||
|
def raiser(path):
|
||||||
|
path = os.path.realpath(os.path.abspath(path))
|
||||||
|
if not path.startswith(temp_root):
|
||||||
|
raise OSError(path)
|
||||||
|
chdir(path)
|
||||||
|
with mock.patch('os.chdir', raiser):
|
||||||
|
spawnermod._try_setcwd(cwd)
|
||||||
|
assert os.getcwd().startswith(temp_root)
|
||||||
|
os.chdir(cwd)
|
||||||
|
@@ -21,7 +21,7 @@ class Command(List):
|
|||||||
kwargs.setdefault('minlen', 1)
|
kwargs.setdefault('minlen', 1)
|
||||||
if isinstance(default_value, str):
|
if isinstance(default_value, str):
|
||||||
default_value = [default_value]
|
default_value = [default_value]
|
||||||
super().__init__(Unicode, default_value, **kwargs)
|
super().__init__(Unicode(), default_value, **kwargs)
|
||||||
|
|
||||||
def validate(self, obj, value):
|
def validate(self, obj, value):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
@@ -12,7 +12,7 @@ from sqlalchemy import inspect
|
|||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
|
|
||||||
from . import orm
|
from . import orm
|
||||||
from traitlets import HasTraits, Any, Dict
|
from traitlets import HasTraits, Any, Dict, observe, default
|
||||||
from .spawner import LocalProcessSpawner
|
from .spawner import LocalProcessSpawner
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +38,12 @@ class UserDict(dict):
|
|||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
if isinstance(key, User):
|
if isinstance(key, User):
|
||||||
key = key.id
|
key = key.id
|
||||||
|
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" % key)
|
||||||
|
else:
|
||||||
|
key = orm_user
|
||||||
if isinstance(key, orm.User):
|
if isinstance(key, orm.User):
|
||||||
# users[orm_user] returns User(orm_user)
|
# users[orm_user] returns User(orm_user)
|
||||||
orm_user = key
|
orm_user = key
|
||||||
@@ -69,22 +75,24 @@ class UserDict(dict):
|
|||||||
|
|
||||||
class User(HasTraits):
|
class User(HasTraits):
|
||||||
|
|
||||||
|
@default('log')
|
||||||
def _log_default(self):
|
def _log_default(self):
|
||||||
return app_log
|
return app_log
|
||||||
|
|
||||||
settings = Dict()
|
settings = Dict()
|
||||||
|
|
||||||
db = Any(allow_none=True)
|
db = Any(allow_none=True)
|
||||||
|
@default('db')
|
||||||
def _db_default(self):
|
def _db_default(self):
|
||||||
if self.orm_user:
|
if self.orm_user:
|
||||||
return inspect(self.orm_user).session
|
return inspect(self.orm_user).session
|
||||||
|
@observe('db')
|
||||||
def _db_changed(self, name, old, new):
|
def _db_changed(self, change):
|
||||||
"""Changing db session reacquires ORM User object"""
|
"""Changing db session reacquires ORM User object"""
|
||||||
# db session changed, re-get orm User
|
# db session changed, re-get orm User
|
||||||
if self.orm_user:
|
if self.orm_user:
|
||||||
id = self.orm_user.id
|
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
|
self.spawner.db = self.db
|
||||||
|
|
||||||
orm_user = None
|
orm_user = None
|
||||||
@@ -139,6 +147,8 @@ class User(HasTraits):
|
|||||||
@property
|
@property
|
||||||
def running(self):
|
def running(self):
|
||||||
"""property for whether a user has a running server"""
|
"""property for whether a user has a running server"""
|
||||||
|
if self.spawn_pending or self.stop_pending:
|
||||||
|
return False # server is not running if spawn or stop is still pending
|
||||||
if self.server is None:
|
if self.server is None:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -148,6 +158,41 @@ class User(HasTraits):
|
|||||||
"""My name, escaped for use in URLs, cookies, etc."""
|
"""My name, escaped for use in URLs, cookies, etc."""
|
||||||
return quote(self.name, safe='@')
|
return quote(self.name, safe='@')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy_path(self):
|
||||||
|
if self.settings.get('subdomain_host'):
|
||||||
|
return url_path_join('/' + self.domain, self.server.base_url)
|
||||||
|
else:
|
||||||
|
return self.server.base_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain(self):
|
||||||
|
"""Get the domain for my server."""
|
||||||
|
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
||||||
|
return self.escaped_name + '.' + self.settings['domain']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self):
|
||||||
|
"""Get the *host* for my server (domain[:port])"""
|
||||||
|
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
|
||||||
|
parsed = urlparse(self.settings['subdomain_host'])
|
||||||
|
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
|
||||||
|
return h
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
"""My URL
|
||||||
|
|
||||||
|
Full name.domain/path if using subdomains, otherwise just my /base/url
|
||||||
|
"""
|
||||||
|
if self.settings.get('subdomain_host'):
|
||||||
|
return '{host}{path}'.format(
|
||||||
|
host=self.host,
|
||||||
|
path=self.base_url,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return self.base_url
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def spawn(self, options=None):
|
def spawn(self, options=None):
|
||||||
"""Start the user's spawner"""
|
"""Start the user's spawner"""
|
||||||
@@ -183,7 +228,7 @@ class User(HasTraits):
|
|||||||
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, gen.TimeoutError):
|
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,
|
user=self.name, s=spawner.start_timeout,
|
||||||
))
|
))
|
||||||
e.reason = 'timeout'
|
e.reason = 'timeout'
|
||||||
@@ -206,11 +251,12 @@ class User(HasTraits):
|
|||||||
self.state = spawner.get_state()
|
self.state = spawner.get_state()
|
||||||
self.last_activity = datetime.utcnow()
|
self.last_activity = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
self.spawn_pending = False
|
||||||
try:
|
try:
|
||||||
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
|
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, TimeoutError):
|
if isinstance(e, TimeoutError):
|
||||||
self.log.warn(
|
self.log.warning(
|
||||||
"{user}'s server never showed up at {url} "
|
"{user}'s server never showed up at {url} "
|
||||||
"after {http_timeout} seconds. Giving up".format(
|
"after {http_timeout} seconds. Giving up".format(
|
||||||
user=self.name,
|
user=self.name,
|
||||||
@@ -232,7 +278,6 @@ class User(HasTraits):
|
|||||||
), exc_info=True)
|
), exc_info=True)
|
||||||
# raise original TimeoutError
|
# raise original TimeoutError
|
||||||
raise e
|
raise e
|
||||||
self.spawn_pending = False
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
|
@@ -30,22 +30,32 @@ def random_port():
|
|||||||
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||||
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
|
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
|
||||||
|
def can_connect(ip, port):
|
||||||
|
"""Check if we can connect to an ip:port
|
||||||
|
|
||||||
|
return True if we can connect, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
socket.create_connection((ip, port))
|
||||||
|
except socket.error as e:
|
||||||
|
if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}:
|
||||||
|
app_log.error("Unexpected error connecting to %s:%i %s",
|
||||||
|
ip, port, e
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_for_server(ip, port, timeout=10):
|
def wait_for_server(ip, port, timeout=10):
|
||||||
"""wait for any server to show up at ip:port"""
|
"""wait for any server to show up at ip:port"""
|
||||||
loop = ioloop.IOLoop.current()
|
loop = ioloop.IOLoop.current()
|
||||||
tic = loop.time()
|
tic = loop.time()
|
||||||
while loop.time() - tic < timeout:
|
while loop.time() - tic < timeout:
|
||||||
try:
|
if can_connect(ip, port):
|
||||||
socket.create_connection((ip, port))
|
|
||||||
except socket.error as e:
|
|
||||||
if e.errno != errno.ECONNREFUSED:
|
|
||||||
app_log.error("Unexpected error waiting for %s:%i %s",
|
|
||||||
ip, port, e
|
|
||||||
)
|
|
||||||
yield gen.sleep(0.1)
|
|
||||||
else:
|
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
yield gen.sleep(0.1)
|
||||||
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
||||||
**locals()
|
**locals()
|
||||||
))
|
))
|
||||||
@@ -68,14 +78,14 @@ def wait_for_http_server(url, timeout=10):
|
|||||||
if e.code != 599:
|
if e.code != 599:
|
||||||
# we expect 599 for no connection,
|
# we expect 599 for no connection,
|
||||||
# but 502 or other proxy error is conceivable
|
# 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)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||||
return
|
return
|
||||||
except (OSError, socket.error) as e:
|
except (OSError, socket.error) as e:
|
||||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
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)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
@@ -195,35 +205,3 @@ def url_path_join(*pieces):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def localhost():
|
|
||||||
"""Return localhost or 127.0.0.1"""
|
|
||||||
if hasattr(localhost, '_localhost'):
|
|
||||||
return localhost._localhost
|
|
||||||
binder = connector = None
|
|
||||||
try:
|
|
||||||
binder = socket.socket()
|
|
||||||
binder.bind(('localhost', 0))
|
|
||||||
binder.listen(1)
|
|
||||||
port = binder.getsockname()[1]
|
|
||||||
def accept():
|
|
||||||
try:
|
|
||||||
conn, addr = binder.accept()
|
|
||||||
except ConnectionAbortedError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
conn.close()
|
|
||||||
t = Thread(target=accept)
|
|
||||||
t.start()
|
|
||||||
connector = socket.create_connection(('localhost', port), timeout=10)
|
|
||||||
t.join(timeout=10)
|
|
||||||
except (socket.error, socket.gaierror) as e:
|
|
||||||
warnings.warn("localhost doesn't appear to work, using 127.0.0.1\n%s" % e, RuntimeWarning)
|
|
||||||
localhost._localhost = '127.0.0.1'
|
|
||||||
else:
|
|
||||||
localhost._localhost = 'localhost'
|
|
||||||
finally:
|
|
||||||
if binder:
|
|
||||||
binder.close()
|
|
||||||
if connector:
|
|
||||||
connector.close()
|
|
||||||
return localhost._localhost
|
|
||||||
|
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
version_info = (
|
version_info = (
|
||||||
0,
|
0,
|
||||||
4,
|
6,
|
||||||
1,
|
1,
|
||||||
|
# 'dev',
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = '.'.join(map(str, version_info))
|
__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,4 +1,4 @@
|
|||||||
traitlets>=4
|
traitlets>=4.1
|
||||||
tornado>=4.1
|
tornado>=4.1
|
||||||
jinja2
|
jinja2
|
||||||
pamela
|
pamela
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python
|
||||||
"""Extend regular notebook server to be aware of multiuser things."""
|
"""Extend regular notebook server to be aware of multiuser things."""
|
||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
@@ -17,34 +17,27 @@ from jinja2 import ChoiceLoader, FunctionLoader
|
|||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado.web import HTTPError
|
from tornado.web import HTTPError
|
||||||
|
|
||||||
|
try:
|
||||||
|
import notebook
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
|
||||||
|
|
||||||
from IPython.utils.traitlets import (
|
from traitlets import (
|
||||||
|
Bool,
|
||||||
Integer,
|
Integer,
|
||||||
Unicode,
|
Unicode,
|
||||||
CUnicode,
|
CUnicode,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
from notebook.notebookapp import (
|
||||||
import notebook
|
NotebookApp,
|
||||||
# 4.x
|
aliases as notebook_aliases,
|
||||||
except ImportError:
|
flags as notebook_flags,
|
||||||
from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases
|
)
|
||||||
from IPython.html.auth.login import LoginHandler
|
from notebook.auth.login import LoginHandler
|
||||||
from IPython.html.auth.logout import LogoutHandler
|
from notebook.auth.logout import LogoutHandler
|
||||||
|
|
||||||
from IPython.html.utils import url_path_join
|
from notebook.utils import url_path_join
|
||||||
|
|
||||||
from distutils.version import LooseVersion as V
|
|
||||||
|
|
||||||
import IPython
|
|
||||||
if V(IPython.__version__) < V('3.0'):
|
|
||||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
|
||||||
else:
|
|
||||||
from notebook.notebookapp import NotebookApp, aliases as notebook_aliases
|
|
||||||
from notebook.auth.login import LoginHandler
|
|
||||||
from notebook.auth.logout import LogoutHandler
|
|
||||||
|
|
||||||
from notebook.utils import url_path_join
|
|
||||||
|
|
||||||
|
|
||||||
# Define two methods to attach to AuthenticatedHandler,
|
# Define two methods to attach to AuthenticatedHandler,
|
||||||
@@ -54,7 +47,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def login_available(settings):
|
def login_available(settings):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_token(self, cookie_name, encrypted_cookie):
|
def verify_token(self, cookie_name, encrypted_cookie):
|
||||||
"""method for token verification"""
|
"""method for token verification"""
|
||||||
@@ -62,7 +55,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
if encrypted_cookie in cookie_cache:
|
if encrypted_cookie in cookie_cache:
|
||||||
# we've seen this token before, don't ask upstream again
|
# we've seen this token before, don't ask upstream again
|
||||||
return cookie_cache[encrypted_cookie]
|
return cookie_cache[encrypted_cookie]
|
||||||
|
|
||||||
hub_api_url = self.settings['hub_api_url']
|
hub_api_url = self.settings['hub_api_url']
|
||||||
hub_api_key = self.settings['hub_api_key']
|
hub_api_key = self.settings['hub_api_key']
|
||||||
r = requests.get(url_path_join(
|
r = requests.get(url_path_join(
|
||||||
@@ -85,7 +78,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
cookie_cache[encrypted_cookie] = data
|
cookie_cache[encrypted_cookie] = data
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user(self):
|
def get_user(self):
|
||||||
"""alternative get_current_user to query the central server"""
|
"""alternative get_current_user to query the central server"""
|
||||||
@@ -94,7 +87,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
# since this may be called again when trying to render the error page
|
# since this may be called again when trying to render the error page
|
||||||
if hasattr(self, '_cached_user'):
|
if hasattr(self, '_cached_user'):
|
||||||
return self._cached_user
|
return self._cached_user
|
||||||
|
|
||||||
self._cached_user = None
|
self._cached_user = None
|
||||||
my_user = self.settings['user']
|
my_user = self.settings['user']
|
||||||
encrypted_cookie = self.get_cookie(self.cookie_name)
|
encrypted_cookie = self.get_cookie(self.cookie_name)
|
||||||
@@ -116,7 +109,9 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
|
|
||||||
class JupyterHubLogoutHandler(LogoutHandler):
|
class JupyterHubLogoutHandler(LogoutHandler):
|
||||||
def get(self):
|
def get(self):
|
||||||
self.redirect(url_path_join(self.settings['hub_prefix'], 'logout'))
|
self.redirect(
|
||||||
|
self.settings['hub_host'] +
|
||||||
|
url_path_join(self.settings['hub_prefix'], 'logout'))
|
||||||
|
|
||||||
|
|
||||||
# register new hub related command-line aliases
|
# register new hub related command-line aliases
|
||||||
@@ -125,9 +120,18 @@ aliases.update({
|
|||||||
'user' : 'SingleUserNotebookApp.user',
|
'user' : 'SingleUserNotebookApp.user',
|
||||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||||
|
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||||
'base-url': 'SingleUserNotebookApp.base_url',
|
'base-url': 'SingleUserNotebookApp.base_url',
|
||||||
})
|
})
|
||||||
|
flags = dict(notebook_flags)
|
||||||
|
flags.update({
|
||||||
|
'disable-user-config': ({
|
||||||
|
'SingleUserNotebookApp': {
|
||||||
|
'disable_user_config': True
|
||||||
|
}
|
||||||
|
}, "Disable user-controlled configuration of the notebook server.")
|
||||||
|
})
|
||||||
|
|
||||||
page_template = """
|
page_template = """
|
||||||
{% extends "templates/page.html" %}
|
{% extends "templates/page.html" %}
|
||||||
@@ -141,8 +145,21 @@ page_template = """
|
|||||||
>
|
>
|
||||||
Control Panel</a>
|
Control Panel</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block logo %}
|
||||||
|
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
||||||
|
{% endblock logo %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _exclude_home(path_list):
|
||||||
|
"""Filter out any entries in a path list that are in my home directory.
|
||||||
|
|
||||||
|
Used to disable per-user configuration.
|
||||||
|
"""
|
||||||
|
home = os.path.expanduser('~')
|
||||||
|
for p in path_list:
|
||||||
|
if not p.startswith(home):
|
||||||
|
yield p
|
||||||
|
|
||||||
class SingleUserNotebookApp(NotebookApp):
|
class SingleUserNotebookApp(NotebookApp):
|
||||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||||
user = CUnicode(config=True)
|
user = CUnicode(config=True)
|
||||||
@@ -150,12 +167,23 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
self.log.name = new
|
self.log.name = new
|
||||||
cookie_name = Unicode(config=True)
|
cookie_name = Unicode(config=True)
|
||||||
hub_prefix = Unicode(config=True)
|
hub_prefix = Unicode(config=True)
|
||||||
|
hub_host = Unicode(config=True)
|
||||||
hub_api_url = Unicode(config=True)
|
hub_api_url = Unicode(config=True)
|
||||||
aliases = aliases
|
aliases = aliases
|
||||||
|
flags = flags
|
||||||
open_browser = False
|
open_browser = False
|
||||||
trust_xheaders = True
|
trust_xheaders = True
|
||||||
login_handler_class = JupyterHubLoginHandler
|
login_handler_class = JupyterHubLoginHandler
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use
|
||||||
|
|
||||||
|
disable_user_config = Bool(False, config=True,
|
||||||
|
help="""Disable user configuration of single-user server.
|
||||||
|
|
||||||
|
Prevents user-writable files that normally configure the single-user server
|
||||||
|
from being loaded, ensuring admins have full control of configuration.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
cookie_cache_lifetime = Integer(
|
cookie_cache_lifetime = Integer(
|
||||||
config=True,
|
config=True,
|
||||||
@@ -182,7 +210,37 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
def _clear_cookie_cache(self):
|
def _clear_cookie_cache(self):
|
||||||
self.log.debug("Clearing cookie cache")
|
self.log.debug("Clearing cookie cache")
|
||||||
self.tornado_settings['cookie_cache'].clear()
|
self.tornado_settings['cookie_cache'].clear()
|
||||||
|
|
||||||
|
def migrate_config(self):
|
||||||
|
if self.disable_user_config:
|
||||||
|
# disable config-migration when user config is disabled
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
super(SingleUserNotebookApp, self).migrate_config()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_file_paths(self):
|
||||||
|
path = super(SingleUserNotebookApp, self).config_file_paths
|
||||||
|
|
||||||
|
if self.disable_user_config:
|
||||||
|
# filter out user-writable config dirs if user config is disabled
|
||||||
|
path = list(_exclude_home(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nbextensions_path(self):
|
||||||
|
path = super(SingleUserNotebookApp, self).nbextensions_path
|
||||||
|
|
||||||
|
if self.disable_user_config:
|
||||||
|
path = list(_exclude_home(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _static_custom_path_default(self):
|
||||||
|
path = super(SingleUserNotebookApp, self)._static_custom_path_default()
|
||||||
|
if self.disable_user_config:
|
||||||
|
path = list(_exclude_home(path))
|
||||||
|
return path
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Start a PeriodicCallback to clear cached cookies. This forces us to
|
# Start a PeriodicCallback to clear cached cookies. This forces us to
|
||||||
# revalidate our user with the Hub at least every
|
# revalidate our user with the Hub at least every
|
||||||
@@ -193,7 +251,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
self.cookie_cache_lifetime * 1e3,
|
self.cookie_cache_lifetime * 1e3,
|
||||||
).start()
|
).start()
|
||||||
super(SingleUserNotebookApp, self).start()
|
super(SingleUserNotebookApp, self).start()
|
||||||
|
|
||||||
def init_webapp(self):
|
def init_webapp(self):
|
||||||
# load the hub related settings into the tornado settings dict
|
# load the hub related settings into the tornado settings dict
|
||||||
env = os.environ
|
env = os.environ
|
||||||
@@ -202,26 +260,28 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
s['user'] = self.user
|
s['user'] = self.user
|
||||||
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
||||||
s['hub_prefix'] = self.hub_prefix
|
s['hub_prefix'] = self.hub_prefix
|
||||||
|
s['hub_host'] = self.hub_host
|
||||||
s['cookie_name'] = self.cookie_name
|
s['cookie_name'] = self.cookie_name
|
||||||
s['login_url'] = self.hub_prefix
|
s['login_url'] = self.hub_host + self.hub_prefix
|
||||||
s['hub_api_url'] = self.hub_api_url
|
s['hub_api_url'] = self.hub_api_url
|
||||||
s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
|
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
|
||||||
|
|
||||||
super(SingleUserNotebookApp, self).init_webapp()
|
super(SingleUserNotebookApp, self).init_webapp()
|
||||||
self.patch_templates()
|
self.patch_templates()
|
||||||
|
|
||||||
def patch_templates(self):
|
def patch_templates(self):
|
||||||
"""Patch page templates to add Hub-related buttons"""
|
"""Patch page templates to add Hub-related buttons"""
|
||||||
|
|
||||||
|
self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo')
|
||||||
env = self.web_app.settings['jinja2_env']
|
env = self.web_app.settings['jinja2_env']
|
||||||
|
|
||||||
env.globals['hub_control_panel_url'] = \
|
env.globals['hub_control_panel_url'] = \
|
||||||
url_path_join(self.hub_prefix, 'home')
|
self.hub_host + url_path_join(self.hub_prefix, 'home')
|
||||||
|
|
||||||
# patch jinja env loading to modify page template
|
# patch jinja env loading to modify page template
|
||||||
def get_page(name):
|
def get_page(name):
|
||||||
if name == 'page.html':
|
if name == 'page.html':
|
||||||
return page_template
|
return page_template
|
||||||
|
|
||||||
orig_loader = env.loader
|
orig_loader = env.loader
|
||||||
env.loader = ChoiceLoader([
|
env.loader = ChoiceLoader([
|
||||||
FunctionLoader(get_page),
|
FunctionLoader(get_page),
|
||||||
|
2
setup.py
2
setup.py
@@ -166,7 +166,7 @@ class Bower(BaseCommand):
|
|||||||
|
|
||||||
if self.should_run_npm():
|
if self.should_run_npm():
|
||||||
print("installing build dependencies with 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)
|
os.utime(self.node_modules)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
@@ -152,15 +152,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#add-user").click(function () {
|
$("#add-users").click(function () {
|
||||||
var dialog = $("#add-user-dialog");
|
var dialog = $("#add-users-dialog");
|
||||||
dialog.find(".username-input").val('');
|
dialog.find(".username-input").val('');
|
||||||
dialog.find(".admin-checkbox").prop("checked", false);
|
dialog.find(".admin-checkbox").prop("checked", false);
|
||||||
dialog.modal();
|
dialog.modal();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#add-user-dialog").find(".save-button").click(function () {
|
$("#add-users-dialog").find(".save-button").click(function () {
|
||||||
var dialog = $("#add-user-dialog");
|
var dialog = $("#add-users-dialog");
|
||||||
var lines = dialog.find(".username-input").val().split('\n');
|
var lines = dialog.find(".username-input").val().split('\n');
|
||||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||||
var usernames = [];
|
var usernames = [];
|
||||||
@@ -178,6 +178,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#stop-all-servers").click(function () {
|
||||||
|
$("#stop-all-servers-dialog").modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#stop-all-servers-dialog").find(".stop-all-button").click(function () {
|
||||||
|
// stop all clicks all the active stop buttons
|
||||||
|
$('.stop-server').not('.hidden').click();
|
||||||
|
});
|
||||||
|
|
||||||
$("#shutdown-hub").click(function () {
|
$("#shutdown-hub").click(function () {
|
||||||
var dialog = $("#shutdown-hub-dialog");
|
var dialog = $("#shutdown-hub-dialog");
|
||||||
dialog.find("input[type=checkbox]").prop("checked", true);
|
dialog.find("input[type=checkbox]").prop("checked", true);
|
||||||
|
@@ -32,8 +32,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr class="user-row add-user-row">
|
<tr class="user-row add-user-row">
|
||||||
<td colspan="12">
|
<td colspan="12">
|
||||||
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
<a id="add-users" class="col-xs-2 btn btn-default">Add Users</a>
|
||||||
<a id="shutdown-hub" class="col-xs-5 col-xs-offset-2 btn btn-danger">Shutdown Hub</a>
|
<a id="stop-all-servers" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a>
|
||||||
|
<a id="shutdown-hub" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
@@ -71,6 +72,10 @@
|
|||||||
This operation cannot be undone.
|
This operation cannot be undone.
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call modal('Stop All Servers', btn_label='Stop All', btn_class='btn-danger stop-all-button') %}
|
||||||
|
Are you sure you want to stop all your users' servers? Kernels will be shutdown and unsaved data may be lost.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
|
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
|
||||||
Are you sure you want to shutdown the Hub?
|
Are you sure you want to shutdown the Hub?
|
||||||
You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below:
|
You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below:
|
||||||
@@ -108,7 +113,7 @@
|
|||||||
|
|
||||||
{{ user_modal('Edit User') }}
|
{{ user_modal('Edit User') }}
|
||||||
|
|
||||||
{{ user_modal('Add User', multi=True) }}
|
{{ user_modal('Add Users', multi=True) }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -17,6 +17,11 @@
|
|||||||
{{message}}
|
{{message}}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if message_html %}
|
||||||
|
<p>
|
||||||
|
{{message_html | safe}}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% endblock error_detail %}
|
{% endblock error_detail %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a id="start" class="btn btn-lg btn-success"
|
<a id="start" class="btn btn-lg btn-success"
|
||||||
{% if user.running %}
|
{% if user.running %}
|
||||||
href="{{base_url}}user/{{user.name}}/"
|
href="{{ user.url }}"
|
||||||
{% else %}
|
{% else %}
|
||||||
href="{{base_url}}spawn"
|
href="{{base_url}}spawn"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
<div id="header" class="navbar navbar-static-top">
|
<div id="header" class="navbar navbar-static-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span id="jupyterhub-logo" class="pull-left"><a href="{{base_url}}"><img src='{{static_url("images/jupyter.png") }}' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
|
<span id="jupyterhub-logo" class="pull-left"><a href="{{logo_url or base_url}}"><img src='{{base_url}}logo' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
|
||||||
|
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
|
|
||||||
|
@@ -133,6 +133,7 @@ def untag(vs, push=False):
|
|||||||
v2 = parse_vs(vs)
|
v2 = parse_vs(vs)
|
||||||
v2.append('dev')
|
v2.append('dev')
|
||||||
v2[1] += 1
|
v2[1] += 1
|
||||||
|
v2[2] = 0
|
||||||
vs2 = unparse_vs(v2)
|
vs2 = unparse_vs(v2)
|
||||||
patch_version(vs2, repo_root)
|
patch_version(vs2, repo_root)
|
||||||
with cd(repo_root):
|
with cd(repo_root):
|
||||||
|
Reference in New Issue
Block a user