mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
379 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 | ||
![]() |
7837a9cf68 | ||
![]() |
65a019e05b | ||
![]() |
f2014c5687 | ||
![]() |
109c315336 | ||
![]() |
941fc7e627 | ||
![]() |
f626d2f6e5 | ||
![]() |
80215f6b3c | ||
![]() |
84916062f0 | ||
![]() |
641154bf06 | ||
![]() |
14b0dbde0e | ||
![]() |
cd85766441 | ||
![]() |
6c072bdb3d | ||
![]() |
35f080458e | ||
![]() |
feac4f6bc4 | ||
![]() |
1bbabbb989 | ||
![]() |
ad5624c7ce | ||
![]() |
a7d6c37d26 | ||
![]() |
b8d9954c28 | ||
![]() |
927a341764 | ||
![]() |
83d092b0ad | ||
![]() |
95f7889803 | ||
![]() |
ceacd72d63 | ||
![]() |
49c0fa4f08 | ||
![]() |
223318bfff | ||
![]() |
9c3f953682 | ||
![]() |
cc4c65bd0b | ||
![]() |
c4fad21850 | ||
![]() |
665907afd3 | ||
![]() |
8a4305a15c | ||
![]() |
7e59148168 | ||
![]() |
98b44d59c4 | ||
![]() |
aac357b715 | ||
![]() |
2632d03dc2 | ||
![]() |
babb2cf908 | ||
![]() |
6a3d790f49 | ||
![]() |
9cae91aeb0 | ||
![]() |
84f8f8f322 | ||
![]() |
bc4973fb43 | ||
![]() |
1a21e822b6 | ||
![]() |
d437a8f06a | ||
![]() |
0555ee44e7 | ||
![]() |
ef40bd230e | ||
![]() |
818510c2ca | ||
![]() |
caaab40944 | ||
![]() |
0fb80d43b6 | ||
![]() |
8146af7240 | ||
![]() |
b9df681115 | ||
![]() |
40a3ebde84 | ||
![]() |
fbf3b45d52 | ||
![]() |
eb0a38c136 | ||
![]() |
37d42a336f | ||
![]() |
51a04258d1 | ||
![]() |
1a4226419f | ||
![]() |
ce4cc62c05 | ||
![]() |
614a0806f5 | ||
![]() |
ff2fef1617 | ||
![]() |
2e6f08268b | ||
![]() |
ff4019128a | ||
![]() |
6fd18840a7 | ||
![]() |
108d710dcb | ||
![]() |
aa93384f47 | ||
![]() |
9441fa37c5 | ||
![]() |
beb2dae6ce | ||
![]() |
887fdaf9d3 | ||
![]() |
8a5a85a489 | ||
![]() |
2cc49d317b | ||
![]() |
4afa358201 | ||
![]() |
50a58e5e81 | ||
![]() |
479b40d840 | ||
![]() |
931c2d6f8a | ||
![]() |
f5746d0765 | ||
![]() |
a59f57e095 | ||
![]() |
47549e752d | ||
![]() |
4534bea86e | ||
![]() |
2815f72250 | ||
![]() |
131b695fbb | ||
![]() |
1bc0d208d3 | ||
![]() |
46a9e8b1c3 | ||
![]() |
04cb5fe503 | ||
![]() |
0ad110f7de | ||
![]() |
0c5c3eb8b1 | ||
![]() |
bd8b8c55b2 | ||
![]() |
e52d2eb27d | ||
![]() |
0b4fbee418 | ||
![]() |
9ee92a3984 | ||
![]() |
f4de573198 | ||
![]() |
26e00718f9 | ||
![]() |
c878e137aa | ||
![]() |
53785a985d | ||
![]() |
b0cc47984b | ||
![]() |
91168fc22b | ||
![]() |
66cbb8a614 | ||
![]() |
0fbd69be9b | ||
![]() |
872005f852 | ||
![]() |
647dd09f40 | ||
![]() |
041c1a4a1e | ||
![]() |
d2e3a73f53 | ||
![]() |
2bd7192e89 | ||
![]() |
28f5f33a76 | ||
![]() |
f9c9c2b471 | ||
![]() |
41ea696546 | ||
![]() |
54f9a296de | ||
![]() |
ba634354dd | ||
![]() |
675f19b5cb | ||
![]() |
1eed96193d | ||
![]() |
faa259e97b | ||
![]() |
4785a1ef87 | ||
![]() |
aa529f3aba | ||
![]() |
98955a5702 | ||
![]() |
2f1a203699 | ||
![]() |
77b31d8542 | ||
![]() |
8fca4e859d | ||
![]() |
8d90a92ef3 | ||
![]() |
37424acabf | ||
![]() |
86a450da77 | ||
![]() |
151dcbafb4 | ||
![]() |
d512ee9f65 | ||
![]() |
e59b3f3ab1 | ||
![]() |
2e7af82865 | ||
![]() |
49d4be002b | ||
![]() |
fa8756767d | ||
![]() |
6f128758db | ||
![]() |
235746a484 | ||
![]() |
37f736cf45 | ||
![]() |
5376291eaa | ||
![]() |
9e738a62d1 | ||
![]() |
8bfe52df4f | ||
![]() |
91ff31f688 | ||
![]() |
b7fe3463cf | ||
![]() |
4931684a2c | ||
![]() |
62d3cc53ef | ||
![]() |
bd002e5340 | ||
![]() |
6f2aefb990 | ||
![]() |
bd3c878c67 | ||
![]() |
c1de376b6a | ||
![]() |
4cc74d287e | ||
![]() |
411a7a0bd8 | ||
![]() |
498c062ee0 | ||
![]() |
d1edbddb77 | ||
![]() |
0c9214ffb7 | ||
![]() |
db0aaf1027 | ||
![]() |
42681f8512 | ||
![]() |
e5c1414b6a | ||
![]() |
d857c20de0 | ||
![]() |
a267174a03 | ||
![]() |
768eeee470 | ||
![]() |
a451f11cd3 | ||
![]() |
63a476f9a6 | ||
![]() |
100b17819d | ||
![]() |
024d8d7378 | ||
![]() |
15e50529ff | ||
![]() |
a1a10be747 | ||
![]() |
a91ee67e74 | ||
![]() |
ea5bfa9999 | ||
![]() |
bea58ee622 | ||
![]() |
b698d4d226 | ||
![]() |
139c7ecacb | ||
![]() |
eefa8fcad7 | ||
![]() |
acaedcd898 | ||
![]() |
a075661bfb | ||
![]() |
f2246df5bb | ||
![]() |
1a3c062512 | ||
![]() |
05e4ab41fe | ||
![]() |
6f3ccb2d3d | ||
![]() |
6e5ce236c1 | ||
![]() |
58437057a1 |
@@ -3,3 +3,4 @@ bench
|
||||
jupyterhub_cookie_secret
|
||||
jupyterhub.sqlite
|
||||
jupyterhub_config.py
|
||||
node_modules
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ node_modules
|
||||
.DS_Store
|
||||
build
|
||||
dist
|
||||
docs/_build
|
||||
.ipynb_checkpoints
|
||||
# ignore config file at the top-level of the repo
|
||||
# but not sub-dirs
|
||||
/jupyterhub_config.py
|
||||
|
10
.travis.yml
10
.travis.yml
@@ -2,6 +2,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- 3.5
|
||||
- 3.4
|
||||
- 3.3
|
||||
before_install:
|
||||
@@ -10,8 +11,11 @@ before_install:
|
||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||
install:
|
||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||
- pip install -f travis-wheels/wheelhouse ipython[notebook]
|
||||
script:
|
||||
- py.test --cov jupyterhub jupyterhub/tests -v
|
||||
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
|
||||
after_success:
|
||||
- coveralls
|
||||
- codecov
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.5
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000
|
||||
|
72
Dockerfile
72
Dockerfile
@@ -1,35 +1,63 @@
|
||||
# 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 jupyter/notebook
|
||||
|
||||
FROM debian:jessie
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
# install nodejs, utf8 locale
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
RUN apt-get -y update && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get -y install npm nodejs nodejs-legacy wget locales git &&\
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
locale-gen C.UTF-8 && \
|
||||
apt-get remove -y locales && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
ENV LANG C.UTF-8
|
||||
|
||||
# install Python with conda
|
||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.0.5-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||
echo 'a7bcd0425d8b6688753946b59681572f63c2241aed77bf0ec6de4c5edc5ceeac */tmp/miniconda.sh' | shasum -a 256 -c - && \
|
||||
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
|
||||
/opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \
|
||||
/opt/conda/bin/pip install --upgrade pip && \
|
||||
rm /tmp/miniconda.sh
|
||||
ENV PATH=/opt/conda/bin:$PATH
|
||||
|
||||
# install js dependencies
|
||||
RUN npm install -g configurable-http-proxy
|
||||
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
||||
|
||||
RUN mkdir -p /srv/
|
||||
ADD . /src/jupyterhub
|
||||
WORKDIR /src/jupyterhub
|
||||
|
||||
# install jupyterhub
|
||||
ADD requirements.txt /tmp/requirements.txt
|
||||
RUN pip3 install -r /tmp/requirements.txt
|
||||
RUN python setup.py js && pip install . && \
|
||||
rm -rf $PWD ~/.cache ~/.npm
|
||||
|
||||
WORKDIR /srv/
|
||||
ADD . /srv/jupyterhub
|
||||
RUN mkdir -p /srv/jupyterhub/
|
||||
WORKDIR /srv/jupyterhub/
|
||||
|
||||
RUN pip3 install .
|
||||
|
||||
WORKDIR /srv/jupyterhub/
|
||||
|
||||
# Derivative containers should add jupyterhub config,
|
||||
# which will be used when starting the application.
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
||||
LABEL org.jupyter.service="jupyterhub"
|
||||
|
||||
CMD ["jupyterhub"]
|
||||
|
@@ -4,7 +4,9 @@ include setupegg.py
|
||||
include bower.json
|
||||
include package.json
|
||||
include *requirements.txt
|
||||
include Dockerfile
|
||||
|
||||
graft onbuild
|
||||
graft jupyterhub
|
||||
graft scripts
|
||||
graft share
|
||||
|
117
README.md
117
README.md
@@ -3,8 +3,13 @@
|
||||
Questions, comments? Visit our Google Group:
|
||||
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||
|
||||
JupyterHub is a multi-user server that 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.
|
||||
|
||||
Three actors:
|
||||
|
||||
@@ -22,63 +27,57 @@ Basic principles:
|
||||
|
||||
## Dependencies
|
||||
|
||||
JupyterHub requires IPython >= 3.0 (current master) and Python >= 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.
|
||||
|
||||
You will need nodejs/npm, which you can get from your package manager:
|
||||
Install [nodejs/npm](https://www.npmjs.com/), which is available from your
|
||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
||||
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
|
||||
(The `nodejs-legacy` package installs the `node` executable,
|
||||
which is required for npm to work on Debian/Ubuntu at this point)
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
|
||||
Then install javascript dependencies:
|
||||
Next, install JavaScript dependencies:
|
||||
|
||||
sudo npm install -g configurable-http-proxy
|
||||
|
||||
### Optional
|
||||
### (Optional) Installation Prerequisite (pip)
|
||||
|
||||
- Notes on `pip` command used in the below installation sections:
|
||||
- `sudo` may be needed for `pip install`, depending on filesystem permissions.
|
||||
- JupyterHub requires Python >= 3.3, so it may be required on some machines to use `pip3` instead
|
||||
of `pip` (especially when you have both Python 2 and Python 3 installed on your machine).
|
||||
If `pip3` is not found on your machine, you can get it by doing:
|
||||
Notes on the `pip` command used in the installation directions below:
|
||||
- `sudo` may be needed for `pip install`, depending on the user's filesystem permissions.
|
||||
- JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu):
|
||||
|
||||
sudo apt-get install python3-pip
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
As usual start with cloning the code:
|
||||
JupyterHub can be installed with pip, and the proxy with npm:
|
||||
|
||||
git clone https://github.com/jupyter/jupyterhub.git
|
||||
cd jupyterhub
|
||||
npm install -g configurable-http-proxy
|
||||
pip3 install jupyterhub
|
||||
|
||||
Then you can install the Python package by doing:
|
||||
If you plan to run notebook servers locally, you may also need to install the
|
||||
Jupyter ~~IPython~~ notebook:
|
||||
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install .
|
||||
|
||||
If the `pip3 install .` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional javascript dependencies:
|
||||
|
||||
npm install
|
||||
|
||||
If you plan to run notebook servers locally, you may also need to install the IPython notebook:
|
||||
|
||||
pip3 install "ipython[notebook]"
|
||||
|
||||
|
||||
This will fetch client-side javascript dependencies and compile CSS,
|
||||
and install these files to `sys.prefix`/share/jupyter, as well as
|
||||
install any Python dependencies.
|
||||
pip3 install --upgrade notebook
|
||||
|
||||
|
||||
### Development install
|
||||
|
||||
For a development install:
|
||||
For a development install, clone the repository and then install from source:
|
||||
|
||||
git clone https://github.com/jupyterhub/jupyterhub
|
||||
cd jupyterhub
|
||||
pip3 install -r dev-requirements.txt -e .
|
||||
|
||||
In which case you may need to manually update javascript and css after some updates, with:
|
||||
If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies:
|
||||
|
||||
npm install
|
||||
|
||||
This will fetch client-side JavaScript dependencies necessary to compile CSS.
|
||||
|
||||
You may also need to manually update JavaScript and CSS after some development updates, with:
|
||||
|
||||
python3 setup.py js # fetch updated client-side js (changes rarely)
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
@@ -92,22 +91,24 @@ To start the server, run the command:
|
||||
|
||||
and then visit `http://localhost:8000`, and sign in with your unix credentials.
|
||||
|
||||
If you want multiple users to be able to sign into the server, you will need to 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) describes how to run the server
|
||||
as a less privileged user, which requires more configuration of the system.
|
||||
To allow multiple users to sign into the server, you will need to
|
||||
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||
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
|
||||
configuration of the system.
|
||||
|
||||
## Getting started
|
||||
|
||||
see the [getting started doc](docs/getting-started.md) for some of the basics of configuring your JupyterHub deployment.
|
||||
See the [getting started document](docs/source/getting-started.md) for the
|
||||
basics of configuring your JupyterHub deployment.
|
||||
|
||||
### Some examples
|
||||
|
||||
generate a default config file:
|
||||
Generate a default config file:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
spawn the server on 10.0.1.2:443 with https:
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
@@ -115,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.
|
||||
Some examples, meant as illustration and testing of this concept:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
||||
- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
||||
- 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/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
|
||||
|
||||
@@ -124,6 +146,13 @@ We encourage you to ask questions on the mailing list:
|
||||
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
||||
but you can 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
|
||||
- [Project Jupyter website](https://jupyter.org)
|
||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.org/en/latest/) [[PDF](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf)]
|
||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.org/en/latest/index.html) [[PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)]
|
||||
- [Issues](https://github.com/jupyter/jupyterhub/issues)
|
||||
- [Technical support - Jupyter Google Group](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
24
circle.yml
Normal file
24
circle.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
machine:
|
||||
services:
|
||||
- docker
|
||||
|
||||
dependencies:
|
||||
override:
|
||||
- ls
|
||||
|
||||
test:
|
||||
override:
|
||||
- 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,4 +1,5 @@
|
||||
-r requirements.txt
|
||||
coveralls
|
||||
codecov
|
||||
pytest-cov
|
||||
pytest
|
||||
pytest>=2.8
|
||||
notebook
|
||||
|
192
docs/Makefile
Normal file
192
docs/Makefile
Normal file
@@ -0,0 +1,192 @@
|
||||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " applehelp to make an Apple Help Book"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/JupyterHub.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JupyterHub.qhc"
|
||||
|
||||
applehelp:
|
||||
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
|
||||
@echo
|
||||
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
|
||||
@echo "N.B. You won't be able to view it unless you put it in" \
|
||||
"~/Library/Documentation/Help or install it in your application" \
|
||||
"bundle."
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/JupyterHub"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JupyterHub"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
@@ -1,389 +0,0 @@
|
||||
# Getting started with JupyterHub
|
||||
|
||||
This document describes some of the basics of configuring JupyterHub to do what you want.
|
||||
JupyterHub is highly customizable, so there's a lot to cover.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
See [the readme](../README.md) for help installing JupyterHub.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
|
||||
There are three main categories of processes run by the `jupyterhub` command line program:
|
||||
|
||||
- *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system
|
||||
when they log in. The object that starts these processes is called a *Spawner*.
|
||||
- *Proxy*: the public facing part of the server that uses a dynamic proxy to route HTTP requests
|
||||
to the *Hub* and *Single User Servers*.
|
||||
- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* using a *Spawner*.
|
||||
|
||||
## JupyterHub's default behavior
|
||||
|
||||
|
||||
To start JupyterHub in its default configuration, type the following at the command line:
|
||||
|
||||
sudo jupyterhub
|
||||
|
||||
The default Authenticator that ships with JupyterHub authenticates users
|
||||
with their system name and password (via [PAM][]).
|
||||
Any user on the system with a password will be allowed to start a single-user notebook server.
|
||||
|
||||
The default Spawner starts servers locally as each user, one dedicated server per user.
|
||||
These servers listen on localhost, and start in the given user's home directory.
|
||||
|
||||
By default, the *Proxy* listens on all public interfaces on port 8000.
|
||||
Thus you can reach JupyterHub through:
|
||||
|
||||
http://localhost:8000
|
||||
|
||||
or any other public IP or domain pointing to your system.
|
||||
|
||||
In their default configuration, the other services, the *Hub* and *Single-User Servers*,
|
||||
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 [below](#Security) for how to configure JupyterHub to use SSL.
|
||||
|
||||
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*.
|
||||
This file allows the *Hub* to remember what users are running and where,
|
||||
as well as other information enabling you to restart parts of JupyterHub separately.
|
||||
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
|
||||
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
|
||||
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
|
||||
The cookie secret file is discussed [below](#Security).
|
||||
|
||||
The location of these files can be specified via configuration, discussed below.
|
||||
|
||||
|
||||
## How to configure JupyterHub
|
||||
|
||||
JupyterHub is configured in two ways:
|
||||
|
||||
1. Command-line arguments
|
||||
2. Configuration files
|
||||
|
||||
Type the following for brief information about the command line arguments:
|
||||
|
||||
jupyterhub -h
|
||||
|
||||
or:
|
||||
|
||||
jupyterhub --help-all
|
||||
|
||||
for the full command line help.
|
||||
|
||||
By default, JupyterHub will look for a configuration file (can be missing)
|
||||
named `jupyterhub_config.py` in the current working directory.
|
||||
You can create an empty configuration file with
|
||||
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
This empty configuration file has descriptions of all configuration variables and their default values.
|
||||
You can load a specific config file with:
|
||||
|
||||
|
||||
jupyterhub -f /path/to/jupyterhub_config.py
|
||||
|
||||
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||
on the config system Jupyter uses.
|
||||
|
||||
|
||||
## Networking
|
||||
|
||||
In most situations you will want to change the main IP address and port of the Proxy.
|
||||
This address determines where JupyterHub is available to your users.
|
||||
The default is all network interfaces (`''`) on port 8000.
|
||||
|
||||
This can be done with the following command line arguments:
|
||||
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
|
||||
Or you can put the following lines in a configuration file:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ip = '192.168.1.2'
|
||||
c.JupyterHub.port = 443
|
||||
```
|
||||
|
||||
Port 443 is used in these examples as it is the default port for SSL/HTTPS.
|
||||
|
||||
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
|
||||
However, for more customized scenarios,
|
||||
you can configure the following additional networking details.
|
||||
|
||||
The Hub service talks to the proxy via a REST API on a secondary port,
|
||||
whose network interface and port can be configured separately.
|
||||
By default, this REST API listens on port 8081 of localhost only.
|
||||
If you want to run the Proxy separate from the Hub,
|
||||
you may need to configure this IP and port with:
|
||||
|
||||
```python
|
||||
# ideally a private network address
|
||||
c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
||||
c.JupyterHub.proxy_api_port = 5432
|
||||
```
|
||||
|
||||
The Hub service also listens only on localhost (port 8080) by default.
|
||||
The Hub needs needs to be accessible from both the proxy and all Spawners.
|
||||
When spawning local servers localhost is fine,
|
||||
but if *either* the Proxy or (more likely) the Spawners will be remote or isolated in containers,
|
||||
the Hub must listen on an IP that is accessible.
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_ip = '10.0.1.4'
|
||||
c.JupyterHub.hub_port = 54321
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
First of all, since JupyterHub includes authentication and allows arbitrary code execution,
|
||||
you should not run it without SSL (HTTPS).
|
||||
This will require you to obtain an official SSL certificate or create a self-signed certificate.
|
||||
Once you have obtained and installed a key and certificate
|
||||
you need to pass their locations to JupyterHub's configuration as follows:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/path/to/my.key'
|
||||
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||
```
|
||||
|
||||
Some cert files also contain the key, in which case only the cert is needed.
|
||||
It is important that these files be put in a secure location on your server.
|
||||
|
||||
There are two other aspects of JupyterHub network security.
|
||||
|
||||
The cookie secret is an encryption key, used to encrypt the cookies used for authentication.
|
||||
If this value changes for the Hub,
|
||||
all single-user servers must also be restarted.
|
||||
Normally, this value is stored in the file `jupyterhub_cookie_secret`,
|
||||
which can be specified with:
|
||||
|
||||
```python
|
||||
c.JupyterHub.cookie_secret_file = '/path/to/jupyterhub_cookie_secret'
|
||||
```
|
||||
|
||||
In most deployments of JupyterHub, you should point this to a secure location on the file system.
|
||||
If the cookie secret file doesn't exist when the Hub starts,
|
||||
a new cookie secret is generated and stored in the file.
|
||||
|
||||
If you would like to avoid the need for files,
|
||||
the value can be loaded in the Hub process from the `JPY_COOKIE_SECRET` env variable:
|
||||
|
||||
```bash
|
||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||
```
|
||||
|
||||
For security reasons, this env variable should only be visible to the Hub.
|
||||
|
||||
The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`.
|
||||
If you want to be able to start or restart the proxy or Hub independently of each other (not always necessary),
|
||||
you must set this environment variable before starting the server (for both the Hub and Proxy):
|
||||
|
||||
|
||||
```bash
|
||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
```
|
||||
|
||||
This env variable needs to be visible to the Hub and Proxy.
|
||||
If you don't set this, the Hub will generate a random key itself,
|
||||
which means that any time you restart the Hub you **must also restart the Proxy**.
|
||||
If the proxy is a subprocess of the Hub,
|
||||
this should happen automatically (this is the default configuration).
|
||||
|
||||
|
||||
|
||||
## Configuring Authentication
|
||||
|
||||
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
|
||||
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
|
||||
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||
|
||||
|
||||
```python
|
||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
```
|
||||
|
||||
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
||||
such as stopping and restarting their servers,
|
||||
and adding and removing new users from the whitelist.
|
||||
Any users in the admin list are automatically added to the whitelist,
|
||||
if they are not already present.
|
||||
The set of initial Admin users can configured as follows:
|
||||
|
||||
```python
|
||||
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||
```
|
||||
|
||||
If `JupyterHub.admin_access` is True (not default),
|
||||
then admin users have permission to log in *as other users* on their respective machines, for debugging.
|
||||
**You should make sure your users know if admin_access is enabled.**
|
||||
|
||||
### Adding and removing users
|
||||
|
||||
Users can be added and removed to the Hub via the admin panel or REST API.
|
||||
These users will be added to the whitelist and database.
|
||||
Restarting the Hub will not require manually updating the whitelist in your config file,
|
||||
as the users will be loaded from the database.
|
||||
This means that after starting the Hub once,
|
||||
it is not sufficient to remove users from the whitelist in your config file.
|
||||
You must also remove them from the database, either by discarding the database file,
|
||||
or via the admin UI.
|
||||
|
||||
The default PAMAuthenticator is one case of a special kind of authenticator,
|
||||
called a LocalAuthenticator,
|
||||
indicating that it manages users on the local system.
|
||||
When you add a user to the Hub, a LocalAuthenticator checks if that user already exists.
|
||||
Normally, there will be an error telling you that the user doesn't exist.
|
||||
If you set the configuration value
|
||||
|
||||
|
||||
```python
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
```
|
||||
|
||||
however, adding a user to the Hub that doesn't already exist on the system
|
||||
will result in the Hub creating that user via the system `useradd` mechanism.
|
||||
This option is typically used on hosted deployments of JupyterHub,
|
||||
to avoid the need to manually create all your users before launching the service.
|
||||
It is not recommended when running JupyterHub in situations where JupyterHub users maps directly onto UNIX users.
|
||||
|
||||
|
||||
## Configuring single-user servers
|
||||
|
||||
Since the single-user server is an instance of `ipython notebook`,
|
||||
an entire separate multi-process application,
|
||||
there are many aspect of that server can configure,
|
||||
and a lot of ways to express that configuration.
|
||||
|
||||
At the JupyterHub level, you can set some values on the Spawner.
|
||||
The simplest of these is `Spawner.notebook_dir`,
|
||||
which lets you set the root directory for a user's server.
|
||||
This root notebook directory is the highest level directory users will be able to access in the notebook dashboard.
|
||||
In this example, the root notebook directory is set to `~/notebooks`,
|
||||
where `~` is expanded to the user's home directory.
|
||||
|
||||
```python
|
||||
c.Spawner.notebook_dir = '~/notebooks'
|
||||
```
|
||||
|
||||
You can also specify extra command-line arguments to the notebook server with:
|
||||
|
||||
```python
|
||||
c.Spawner.args = ['--debug', '--profile=PHYS131']
|
||||
```
|
||||
|
||||
This could be used to set the users default page for the single user server:
|
||||
|
||||
```python
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Since the single-user server extends the notebook server application,
|
||||
it still loads configuration from the `ipython_notebook_config.py` config file.
|
||||
Each user may have one of these files in `$HOME/.ipython/profile_default/`.
|
||||
IPython also supports loading system-wide config files from `/etc/ipython/`,
|
||||
which is the place to put configuration that you want to affect all of your users.
|
||||
|
||||
## External services
|
||||
|
||||
JupyterHub has a REST API that can be used to run external services.
|
||||
More detail on this API will be added in the future.
|
||||
|
||||
## File locations
|
||||
|
||||
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
|
||||
|
||||
* `/srv/jupyterhub` for all security and runtime files
|
||||
* `/etc/jupyterhub` for all configuration files
|
||||
* `/var/log` for log files
|
||||
|
||||
## Example
|
||||
|
||||
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
|
||||
|
||||
* JupyterHub is running on a single cloud server
|
||||
* Using SSL on the standard HTTPS port 443
|
||||
* You want to use [GitHub OAuth][oauthenticator] for login
|
||||
* You need the users to exist locally on the server
|
||||
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
|
||||
other users home directories
|
||||
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
|
||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
c = get_config()
|
||||
|
||||
import os
|
||||
pjoin = os.path.join
|
||||
|
||||
runtime_dir = os.path.join('/srv/jupyterhub')
|
||||
ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
os.makedirs(ssl_dir)
|
||||
|
||||
|
||||
# https on :443
|
||||
c.JupyterHub.port = 443
|
||||
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
||||
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
||||
|
||||
# put the JupyterHub cookie secret and state db
|
||||
# in /var/run/jupyterhub
|
||||
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||
|
||||
# put the log file in /var/log
|
||||
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
|
||||
|
||||
# use GitHub OAuthenticator for local users
|
||||
|
||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||
# create system users that don't exist yet
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
|
||||
# specify users and admin
|
||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||
|
||||
# start single-user notebook servers in ~/assignments,
|
||||
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||
# this config could also be put in
|
||||
# /etc/ipython/ipython_notebook_config.py
|
||||
c.Spawner.notebook_dir = '~/assignments'
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
|
||||
which we will need to set when we launch the server:
|
||||
|
||||
```bash
|
||||
export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /path/to/aboveconfig.py
|
||||
```
|
||||
|
||||
|
||||
# Further reading
|
||||
|
||||
- TODO: troubleshooting
|
||||
- [Custom Authenticators](authenticators.md)
|
||||
- [Custom Spawners](spawners.md)
|
||||
|
||||
|
||||
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
||||
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
||||
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module
|
263
docs/make.bat
Normal file
263
docs/make.bat
Normal file
@@ -0,0 +1,263 @@
|
||||
@ECHO OFF
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set BUILDDIR=build
|
||||
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
|
||||
set I18NSPHINXOPTS=%SPHINXOPTS% source
|
||||
if NOT "%PAPER%" == "" (
|
||||
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
|
||||
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
if "%1" == "help" (
|
||||
:help
|
||||
echo.Please use `make ^<target^>` where ^<target^> is one of
|
||||
echo. html to make standalone HTML files
|
||||
echo. dirhtml to make HTML files named index.html in directories
|
||||
echo. singlehtml to make a single large HTML file
|
||||
echo. pickle to make pickle files
|
||||
echo. json to make JSON files
|
||||
echo. htmlhelp to make HTML files and a HTML help project
|
||||
echo. qthelp to make HTML files and a qthelp project
|
||||
echo. devhelp to make HTML files and a Devhelp project
|
||||
echo. epub to make an epub
|
||||
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
|
||||
echo. text to make text files
|
||||
echo. man to make manual pages
|
||||
echo. texinfo to make Texinfo files
|
||||
echo. gettext to make PO message catalogs
|
||||
echo. changes to make an overview over all changed/added/deprecated items
|
||||
echo. xml to make Docutils-native XML files
|
||||
echo. pseudoxml to make pseudoxml-XML files for display purposes
|
||||
echo. linkcheck to check all external links for integrity
|
||||
echo. doctest to run all doctests embedded in the documentation if enabled
|
||||
echo. coverage to run coverage check of the documentation if enabled
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "clean" (
|
||||
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
|
||||
del /q /s %BUILDDIR%\*
|
||||
goto end
|
||||
)
|
||||
|
||||
|
||||
REM Check if sphinx-build is available and fallback to Python version if any
|
||||
%SPHINXBUILD% 1>NUL 2>NUL
|
||||
if errorlevel 9009 goto sphinx_python
|
||||
goto sphinx_ok
|
||||
|
||||
:sphinx_python
|
||||
|
||||
set SPHINXBUILD=python -m sphinx.__init__
|
||||
%SPHINXBUILD% 2> nul
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:sphinx_ok
|
||||
|
||||
|
||||
if "%1" == "html" (
|
||||
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "dirhtml" (
|
||||
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "singlehtml" (
|
||||
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pickle" (
|
||||
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the pickle files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "json" (
|
||||
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the JSON files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "htmlhelp" (
|
||||
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run HTML Help Workshop with the ^
|
||||
.hhp project file in %BUILDDIR%/htmlhelp.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "qthelp" (
|
||||
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run "qcollectiongenerator" with the ^
|
||||
.qhcp project file in %BUILDDIR%/qthelp, like this:
|
||||
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\JupyterHub.qhcp
|
||||
echo.To view the help file:
|
||||
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\JupyterHub.ghc
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "devhelp" (
|
||||
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "epub" (
|
||||
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The epub file is in %BUILDDIR%/epub.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latex" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdf" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf
|
||||
cd %~dp0
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdfja" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf-ja
|
||||
cd %~dp0
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "text" (
|
||||
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The text files are in %BUILDDIR%/text.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "man" (
|
||||
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The manual pages are in %BUILDDIR%/man.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "texinfo" (
|
||||
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "gettext" (
|
||||
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "changes" (
|
||||
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.The overview file is in %BUILDDIR%/changes.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "linkcheck" (
|
||||
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Link check complete; look for any errors in the above output ^
|
||||
or in %BUILDDIR%/linkcheck/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "doctest" (
|
||||
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of doctests in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/doctest/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "coverage" (
|
||||
%SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of coverage in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/coverage/python.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "xml" (
|
||||
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The XML files are in %BUILDDIR%/xml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pseudoxml" (
|
||||
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
|
||||
goto end
|
||||
)
|
||||
|
||||
:end
|
3
docs/requirements.txt
Normal file
3
docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
-r ../requirements.txt
|
||||
sphinx>=1.3.6
|
||||
recommonmark==0.4.0
|
259
docs/rest-api.yml
Normal file
259
docs/rest-api.yml
Normal file
@@ -0,0 +1,259 @@
|
||||
# see me at: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: JupyterHub
|
||||
description: The REST API for JupyterHub
|
||||
version: 0.4.0
|
||||
schemes:
|
||||
- http
|
||||
securityDefinitions:
|
||||
token:
|
||||
type: apiKey
|
||||
name: Authorization
|
||||
in: header
|
||||
security:
|
||||
- token: []
|
||||
basePath: /hub/api/
|
||||
produces:
|
||||
- application/json
|
||||
consumes:
|
||||
- application/json
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
responses:
|
||||
'200':
|
||||
description: The user list
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/User'
|
||||
post:
|
||||
summary: Create multiple users
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
usernames:
|
||||
type: array
|
||||
description: list of usernames to create
|
||||
items:
|
||||
type: string
|
||||
admin:
|
||||
description: whether the created users should be admins
|
||||
type: boolean
|
||||
responses:
|
||||
'201':
|
||||
description: The users have been created
|
||||
schema:
|
||||
type: array
|
||||
description: The created users
|
||||
items:
|
||||
$ref: '#/definitions/User'
|
||||
/users/{name}:
|
||||
get:
|
||||
summary: Get a user by name
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The User model
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
post:
|
||||
summary: Create a single user
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The user has been created
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
delete:
|
||||
summary: Delete a user
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The user has been deleted
|
||||
patch:
|
||||
summary: Modify a user
|
||||
description: Change a user's name or admin status
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
description: Updated user info. At least one of name and admin is required.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: the new name (optional)
|
||||
admin:
|
||||
type: boolean
|
||||
description: update admin (optional)
|
||||
responses:
|
||||
'200':
|
||||
description: The updated user info
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
/users/{name}/server:
|
||||
post:
|
||||
summary: Start a user's server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: The server has started
|
||||
'202':
|
||||
description: The server has been requested, but has not yet started
|
||||
delete:
|
||||
summary: Stop a user's server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: The server has stopped
|
||||
'202':
|
||||
description: The server has been asked to stop, but is taking a while
|
||||
/users/{name}/admin-access:
|
||||
post:
|
||||
summary: Grant an admin access to this user's server
|
||||
parameters:
|
||||
- name: name
|
||||
description: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Sets a cookie granting the requesting admin access to the user's server
|
||||
/proxy:
|
||||
get:
|
||||
summary: Get the proxy's routing table
|
||||
description: A convenience alias for getting the info directly from the proxy
|
||||
responses:
|
||||
'200':
|
||||
description: Routing table
|
||||
schema:
|
||||
type: object
|
||||
description: configurable-http-proxy routing table (see CHP docs for details)
|
||||
post:
|
||||
summary: Force the Hub to sync with the proxy
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
patch:
|
||||
summary: Tell the Hub about a new proxy
|
||||
description: If you have started a new proxy and would like the Hub to switch over to it, this allows you to notify the Hub of the new proxy.
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
description: Any values that have changed for the new proxy. All keys are optional.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
description: IP address of the new proxy
|
||||
port:
|
||||
type: string
|
||||
description: Port of the new proxy
|
||||
protocol:
|
||||
type: string
|
||||
description: Protocol of new proxy, if changed
|
||||
auth_token:
|
||||
type: string
|
||||
description: CONFIGPROXY_AUTH_TOKEN for the new proxy
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
/authorizations/token/{token}:
|
||||
get:
|
||||
summary: Identify a user from an API token
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The user identified by the API token
|
||||
schema:
|
||||
$ref: '#!/definitions/User'
|
||||
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
||||
get:
|
||||
summary: Identify a user from a cookie
|
||||
description: Used by single-user servers to hand off cookie authentication to the Hub
|
||||
parameters:
|
||||
- name: cookie_name
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: cookie_value
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The user identified by the cookie
|
||||
schema:
|
||||
$ref: '#!/definitions/User'
|
||||
/shutdown:
|
||||
post:
|
||||
summary: Shutdown the Hub
|
||||
responses:
|
||||
'200':
|
||||
description: Hub has shutdown
|
||||
definitions:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The user's name
|
||||
admin:
|
||||
type: boolean
|
||||
description: Whether the user is an admin
|
||||
server:
|
||||
type: string
|
||||
description: The user's server's base URL, if running; null if not.
|
||||
pending:
|
||||
type: string
|
||||
enum: ["spawn", "stop"]
|
||||
description: The currently pending action, if any
|
||||
last_activity:
|
||||
type: string
|
||||
format: ISO8601 Timestamp
|
||||
description: Timestamp of last-seen activity from the user
|
21
docs/source/api/auth.rst
Normal file
21
docs/source/api/auth.rst
Normal file
@@ -0,0 +1,21 @@
|
||||
==============
|
||||
Authenticators
|
||||
==============
|
||||
|
||||
Module: :mod:`jupyterhub.auth`
|
||||
==============================
|
||||
|
||||
.. automodule:: jupyterhub.auth
|
||||
|
||||
.. currentmodule:: jupyterhub.auth
|
||||
|
||||
|
||||
|
||||
.. autoclass:: Authenticator
|
||||
:members:
|
||||
|
||||
.. autoclass:: LocalAuthenticator
|
||||
:members:
|
||||
|
||||
.. autoclass:: PAMAuthenticator
|
||||
|
14
docs/source/api/index.rst
Normal file
14
docs/source/api/index.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
.. _api-index:
|
||||
|
||||
####################
|
||||
The JupyterHub API
|
||||
####################
|
||||
|
||||
:Release: |release|
|
||||
:Date: |today|
|
||||
|
||||
.. toctree::
|
||||
|
||||
auth
|
||||
spawner
|
||||
user
|
18
docs/source/api/spawner.rst
Normal file
18
docs/source/api/spawner.rst
Normal file
@@ -0,0 +1,18 @@
|
||||
==============
|
||||
Spawners
|
||||
==============
|
||||
|
||||
Module: :mod:`jupyterhub.spawner`
|
||||
=================================
|
||||
|
||||
.. automodule:: jupyterhub.spawner
|
||||
|
||||
.. currentmodule:: jupyterhub.spawner
|
||||
|
||||
:class:`Spawner`
|
||||
----------------
|
||||
|
||||
.. autoclass:: Spawner
|
||||
:members: options_from_form, poll, start, stop, get_args, get_env, get_state
|
||||
|
||||
.. autoclass:: LocalProcessSpawner
|
31
docs/source/api/user.rst
Normal file
31
docs/source/api/user.rst
Normal file
@@ -0,0 +1,31 @@
|
||||
=============
|
||||
Users
|
||||
=============
|
||||
|
||||
Module: :mod:`jupyterhub.user`
|
||||
==============================
|
||||
|
||||
.. automodule:: jupyterhub.user
|
||||
|
||||
.. currentmodule:: jupyterhub.user
|
||||
|
||||
:class:`User`
|
||||
-------------
|
||||
|
||||
.. class:: Server
|
||||
|
||||
.. autoclass:: User
|
||||
:members: escaped_name
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
The user's name
|
||||
|
||||
.. attribute:: server
|
||||
|
||||
The user's Server data object if running, None otherwise.
|
||||
Has ``ip``, ``port`` attributes.
|
||||
|
||||
.. attribute:: spawner
|
||||
|
||||
The user's :class:`~.Spawner` instance.
|
@@ -63,6 +63,37 @@ For local user authentication (e.g. PAM), this lets you limit which users
|
||||
can login.
|
||||
|
||||
|
||||
## Normalizing and validating usernames
|
||||
|
||||
Since the Authenticator and Spawner both use the same username,
|
||||
sometimes you want to transform the name coming from the authentication service
|
||||
(e.g. turning email addresses into local system usernames) before adding them to the Hub service.
|
||||
Authenticators can define `normalize_username`, which takes a username.
|
||||
The default normalization is to cast names to lowercase
|
||||
|
||||
For simple mappings, a configurable dict `Authenticator.username_map` is used to turn one name into another:
|
||||
|
||||
```python
|
||||
c.Authenticator.username_map = {
|
||||
'service-name': 'localname'
|
||||
}
|
||||
```
|
||||
|
||||
### Validating usernames
|
||||
|
||||
In most cases, there is a very limited set of acceptable usernames.
|
||||
Authenticators can define `validate_username(username)`,
|
||||
which should return True for a valid username and False for an invalid one.
|
||||
The primary effect this has is improving error messages during user creation.
|
||||
|
||||
The default behavior is to use configurable `Authenticator.username_pattern`,
|
||||
which is a regular expression string for validation.
|
||||
|
||||
To only allow usernames that start with 'w':
|
||||
|
||||
c.Authenticator.username_pattern = r'w.*'
|
||||
|
||||
|
||||
## OAuth and other non-password logins
|
||||
|
||||
Some login mechanisms, such as [OAuth][], don't map onto username+password.
|
||||
@@ -72,9 +103,9 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut
|
||||
at [OAuthenticator][].
|
||||
|
||||
|
||||
[Authenticator]: ../jupyterhub/auth.py
|
||||
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[OAuth]: http://en.wikipedia.org/wiki/OAuth
|
||||
[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[OAuthenticator]: https://github.com/jupyter/oauthenticator
|
||||
|
76
docs/source/changelog.md
Normal file
76
docs/source/changelog.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Summary of changes in JupyterHub
|
||||
|
||||
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.1
|
||||
|
||||
Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
|
||||
### 0.4.0
|
||||
|
||||
- Add `Spawner.user_options_form` for specifying an HTML form to present to users,
|
||||
allowing users to influence the spawning of their own servers.
|
||||
- Add `Authenticator.pre_spawn_start` and `Authenticator.post_spawn_stop` hooks,
|
||||
so that Authenticators can do setup or teardown (e.g. passing credentials to Spawner,
|
||||
mounting data sources, etc.).
|
||||
These methods are typically used with custom Authenticator+Spawner pairs.
|
||||
- 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0.
|
||||
|
||||
|
||||
## 0.3
|
||||
|
||||
- No longer make the user starting the Hub an admin
|
||||
- start PAM sessions on login
|
||||
- hooks for Authenticators to fire before spawners start and after they stop,
|
||||
allowing deeper interaction between Spawner/Authenticator pairs.
|
||||
- login redirect fixes
|
||||
|
||||
## 0.2
|
||||
|
||||
- Based on standalone traitlets instead of IPython.utils.traitlets
|
||||
- multiple users in admin panel
|
||||
- Fixes for usernames that require escaping
|
||||
|
||||
## 0.1
|
||||
|
||||
First preview release
|
||||
|
386
docs/source/conf.py
Normal file
386
docs/source/conf.py
Normal file
@@ -0,0 +1,386 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# JupyterHub documentation build configuration file, created by
|
||||
# sphinx-quickstart on Mon Jan 4 16:31:09 2016.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
|
||||
# Needed for conversion from markdown to html
|
||||
import recommonmark.parser
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
needs_sphinx = '1.3'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.napoleon',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# Jupyter uses recommonmark's parser to convert markdown
|
||||
source_parsers = {
|
||||
'.md': 'recommonmark.parser.CommonMarkParser',
|
||||
}
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = ['.rst', '.md']
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'JupyterHub'
|
||||
copyright = u'2016, Project Jupyter team'
|
||||
author = u'Project Jupyter team'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
# Project Jupyter uses the following to autopopulate version
|
||||
from os.path import dirname
|
||||
root = dirname(dirname(dirname(__file__)))
|
||||
sys.path.insert(0, root)
|
||||
|
||||
import jupyterhub
|
||||
# The short X.Y version.
|
||||
version = '%i.%i' % jupyterhub.version_info[:2]
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = jupyterhub.__version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = []
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
#keep_warnings = False
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
#html_theme_path = []
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#html_extra_path = []
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
|
||||
# Language to be used for generating the HTML full-text search index.
|
||||
# Sphinx supports the following languages:
|
||||
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
|
||||
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr'
|
||||
#html_search_language = 'en'
|
||||
|
||||
# A dictionary with options for the search language support, empty by default.
|
||||
# Now only 'ja' uses this config value
|
||||
#html_search_options = {'type': 'default'}
|
||||
|
||||
# The name of a javascript file (relative to the configuration directory) that
|
||||
# implements a search results scorer. If empty, the default will be used.
|
||||
#html_search_scorer = 'scorer.js'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'JupyterHubdoc'
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'JupyterHub.tex', u'JupyterHub Documentation',
|
||||
u'Project Jupyter team', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'jupyterhub', u'JupyterHub Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'JupyterHub', u'JupyterHub Documentation',
|
||||
author, 'JupyterHub', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#texinfo_no_detailmenu = False
|
||||
|
||||
|
||||
# -- Options for Epub output ----------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
epub_title = project
|
||||
epub_author = author
|
||||
epub_publisher = author
|
||||
epub_copyright = copyright
|
||||
|
||||
# The basename for the epub file. It defaults to the project name.
|
||||
#epub_basename = project
|
||||
|
||||
# The HTML theme for the epub output. Since the default themes are not optimized
|
||||
# for small screen space, using the same theme for HTML and epub output is
|
||||
# usually not wise. This defaults to 'epub', a theme designed to save visual
|
||||
# space.
|
||||
#epub_theme = 'epub'
|
||||
|
||||
# The language of the text. It defaults to the language option
|
||||
# or 'en' if the language is not set.
|
||||
#epub_language = ''
|
||||
|
||||
# The scheme of the identifier. Typical schemes are ISBN or URL.
|
||||
#epub_scheme = ''
|
||||
|
||||
# The unique identifier of the text. This can be a ISBN number
|
||||
# or the project homepage.
|
||||
#epub_identifier = ''
|
||||
|
||||
# A unique identification for the text.
|
||||
#epub_uid = ''
|
||||
|
||||
# A tuple containing the cover image and cover page html template filenames.
|
||||
#epub_cover = ()
|
||||
|
||||
# A sequence of (type, uri, title) tuples for the guide element of content.opf.
|
||||
#epub_guide = ()
|
||||
|
||||
# HTML files that should be inserted before the pages created by sphinx.
|
||||
# The format is a list of tuples containing the path and title.
|
||||
#epub_pre_files = []
|
||||
|
||||
# HTML files shat should be inserted after the pages created by sphinx.
|
||||
# The format is a list of tuples containing the path and title.
|
||||
#epub_post_files = []
|
||||
|
||||
# A list of files that should not be packed into the epub file.
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
# The depth of the table of contents in toc.ncx.
|
||||
#epub_tocdepth = 3
|
||||
|
||||
# Allow duplicate toc entries.
|
||||
#epub_tocdup = True
|
||||
|
||||
# Choose between 'default' and 'includehidden'.
|
||||
#epub_tocscope = 'default'
|
||||
|
||||
# Fix unsupported image types using the Pillow.
|
||||
#epub_fix_images = False
|
||||
|
||||
# Scale large images.
|
||||
#epub_max_image_width = 0
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#epub_show_urls = 'inline'
|
||||
|
||||
# If false, no index is generated.
|
||||
#epub_use_index = True
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
|
||||
# Read The Docs
|
||||
# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# otherwise, readthedocs.org uses their theme by default, so no need to specify it
|
439
docs/source/getting-started.md
Normal file
439
docs/source/getting-started.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Getting started with JupyterHub
|
||||
|
||||
This document describes some of the basics of configuring JupyterHub to do what you want.
|
||||
JupyterHub is highly customizable, so there's a lot to cover.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
See [the readme](https://github.com/jupyter/jupyterhub/blob/master/README.md) for help installing JupyterHub.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
|
||||
There are three main categories of processes run by the `jupyterhub` command line program:
|
||||
|
||||
- **Single User Server**: a dedicated, single-user, Jupyter Notebook is started for each user on the system
|
||||
when they log in. The object that starts these processes is called a Spawner.
|
||||
- **Proxy**: the public facing part of the server that uses a dynamic proxy to route HTTP requests
|
||||
to the Hub and Single User Servers.
|
||||
- **Hub**: manages user accounts and authentication and coordinates Single Users Servers using a Spawner.
|
||||
|
||||
## 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:
|
||||
|
||||
sudo jupyterhub
|
||||
|
||||
The default Authenticator that ships with JupyterHub authenticates users
|
||||
with their system name and password (via [PAM][]).
|
||||
Any user on the system with a password will be allowed to start a single-user notebook server.
|
||||
|
||||
The default Spawner starts servers locally as each user, one dedicated server per user.
|
||||
These servers listen on localhost, and start in the given user's home directory.
|
||||
|
||||
By default, the **Proxy** listens on all public interfaces on port 8000.
|
||||
Thus you can reach JupyterHub through either:
|
||||
|
||||
http://localhost:8000
|
||||
|
||||
or any other public IP or domain pointing to your system.
|
||||
|
||||
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
|
||||
all communicate with each other on localhost only.
|
||||
|
||||
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**.
|
||||
This file allows the **Hub** to remember what users are running and where,
|
||||
as well as other information enabling you to restart parts of JupyterHub separately.
|
||||
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
|
||||
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
|
||||
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
|
||||
The cookie secret file is discussed in the [Cookie Secret documentation](#Cookie secret).
|
||||
|
||||
The location of these files can be specified via configuration, discussed below.
|
||||
|
||||
|
||||
## How to configure JupyterHub
|
||||
|
||||
JupyterHub is configured in two ways:
|
||||
|
||||
1. Configuration file
|
||||
2. Command-line arguments
|
||||
|
||||
### Configuration file
|
||||
By default, JupyterHub will look for a configuration file (which may not be created yet)
|
||||
named `jupyterhub_config.py` in the current working directory.
|
||||
You can create an empty configuration file with:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
This empty configuration file has descriptions of all configuration variables and their default
|
||||
values. You can load a specific config file with:
|
||||
|
||||
jupyterhub -f /path/to/jupyterhub_config.py
|
||||
|
||||
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||
on the config system Jupyter uses.
|
||||
|
||||
### Command-line arguments
|
||||
Type the following for brief information about the command-line arguments:
|
||||
|
||||
jupyterhub -h
|
||||
|
||||
or:
|
||||
|
||||
jupyterhub --help-all
|
||||
|
||||
for the full command line help.
|
||||
|
||||
All configurable options are technically configurable on the command-line,
|
||||
even if some are really inconvenient to type. Just replace the desired option,
|
||||
c.Class.trait, with --Class.trait. For example, to configure
|
||||
c.Spawner.notebook_dir = '~/assignments' from the command-line:
|
||||
|
||||
jupyterhub --Spawner.notebook_dir='~/assignments'
|
||||
|
||||
## Networking
|
||||
|
||||
### Configuring the Proxy's IP address and port
|
||||
The Proxy's main IP address setting determines where JupyterHub is available to users.
|
||||
By default, JupyterHub is configured to be available on all network interfaces
|
||||
(`''`) on port 8000. **Note**: Use of `'*'` is discouraged for IP configuration;
|
||||
instead, use of `'0.0.0.0'` is preferred.
|
||||
|
||||
Changing the IP address and port can be done with the following command line
|
||||
arguments:
|
||||
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
|
||||
Or by placing the following lines in a configuration file:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ip = '192.168.1.2'
|
||||
c.JupyterHub.port = 443
|
||||
```
|
||||
|
||||
Port 443 is used as an example since 443 is the default port for SSL/HTTPS.
|
||||
|
||||
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
|
||||
However, more customized scenarios may need additional networking details to
|
||||
be configured.
|
||||
|
||||
### Configuring the Proxy's REST API communication IP address and port (optional)
|
||||
The Hub service talks to the proxy via a REST API on a secondary port,
|
||||
whose network interface and port can be configured separately.
|
||||
By default, this REST API listens on port 8081 of localhost only.
|
||||
|
||||
If running the Proxy separate from the Hub,
|
||||
configure the REST API communication IP address and port with:
|
||||
|
||||
```python
|
||||
# ideally a private network address
|
||||
c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
||||
c.JupyterHub.proxy_api_port = 5432
|
||||
```
|
||||
|
||||
### Configuring the Hub if Spawners or Proxy are remote or isolated in containers
|
||||
The Hub service also listens only on localhost (port 8080) by default.
|
||||
The Hub needs needs to be accessible from both the proxy and all Spawners.
|
||||
When spawning local servers, an IP address setting of localhost is fine.
|
||||
If *either* the Proxy *or* (more likely) the Spawners will be remote or
|
||||
isolated in containers, the Hub must listen on an IP that is accessible.
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_ip = '10.0.1.4'
|
||||
c.JupyterHub.hub_port = 54321
|
||||
```
|
||||
|
||||
## 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 configuration:
|
||||
|
||||
1. SSL encryption (to enable HTTPS)
|
||||
2. Cookie secret (a key for encrypting browser cookies)
|
||||
3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy)
|
||||
|
||||
## SSL encryption
|
||||
|
||||
Since JupyterHub includes authentication and allows arbitrary code execution, you should not run
|
||||
it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or
|
||||
create a self-signed certificate. Once you have obtained and installed a key and certificate you
|
||||
need to specify their locations in the configuration file as follows:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/path/to/my.key'
|
||||
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||
```
|
||||
|
||||
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain a free, trusted SSL
|
||||
certificate. If you run letsencrypt using the default options, the needed configuration is (replace `your.domain.com` by your fully qualified domain name):
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/your.domain.com/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/your.domain.com/fullchain.pem'
|
||||
```
|
||||
|
||||
Some cert files also contain the key, in which case only the cert is needed. It is important that
|
||||
these files be put in a secure location on your server, where they are not readable by regular
|
||||
users.
|
||||
|
||||
Note: In certain cases, e.g. behind SSL termination in nginx, allowing no SSL
|
||||
running on the hub may be desired. To run the Hub without SSL, you must opt
|
||||
in by configuring and confirming the `--no-ssl` option, added as of [version 0.5](./changelog.html).
|
||||
|
||||
## Cookie secret
|
||||
|
||||
The cookie secret is an encryption key, used to encrypt the browser cookies used for
|
||||
authentication. If this value changes for the Hub, all single-user servers must also be restarted.
|
||||
Normally, this value is stored in a file, the location of which can be specified in a config file
|
||||
as follows:
|
||||
|
||||
```python
|
||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
|
||||
```
|
||||
|
||||
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 2048 > /srv/jupyterhub/cookie_secret
|
||||
```
|
||||
|
||||
In most deployments of JupyterHub, you should point this to a secure location on the file
|
||||
system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when
|
||||
the Hub starts, a new cookie secret is generated and stored in the file. The
|
||||
file must not be readable by group or other or the server won't start.
|
||||
The recommended permissions for the cookie secret file are 600 (owner-only rw).
|
||||
|
||||
|
||||
If you would like to avoid the need for files, the value can be loaded in the Hub process from
|
||||
the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You
|
||||
can set it this way:
|
||||
|
||||
```bash
|
||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||
```
|
||||
|
||||
For security reasons, this environment variable should only be visible to the Hub.
|
||||
If you set it dynamically as above, all users will be logged out each time the
|
||||
Hub starts.
|
||||
|
||||
You can also set the secret in the configuration file itself as a binary string:
|
||||
|
||||
```python
|
||||
c.JupyterHub.cookie_secret = bytes.fromhex('VERY LONG SECRET HEX STRING')
|
||||
```
|
||||
|
||||
## Proxy authentication token
|
||||
|
||||
The Hub authenticates its requests to the Proxy using a secret token that the Hub and Proxy agree upon. The value of this string should be a random string (for example, generated by `openssl rand -hex 32`). You can pass this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN` environment variable:
|
||||
|
||||
```bash
|
||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
```
|
||||
|
||||
This environment variable needs to be visible to the Hub and Proxy.
|
||||
|
||||
Or you can set the value in the configuration file:
|
||||
|
||||
```python
|
||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
||||
```
|
||||
|
||||
If you don't set the Proxy authentication token, the Hub will generate a random key itself, which
|
||||
means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a
|
||||
subprocess of the Hub, this should happen automatically (this is the default configuration).
|
||||
|
||||
Another time you must set the Proxy authentication token yourself is if you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader) to also be able to connect to the Proxy.
|
||||
|
||||
## Configuring authentication
|
||||
|
||||
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
|
||||
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
|
||||
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||
|
||||
|
||||
```python
|
||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
```
|
||||
|
||||
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
||||
such as stopping and restarting their servers,
|
||||
and adding and removing new users from the whitelist.
|
||||
Any users in the admin list are automatically added to the whitelist,
|
||||
if they are not already present.
|
||||
The set of initial Admin users can configured as follows:
|
||||
|
||||
```python
|
||||
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||
```
|
||||
|
||||
If `JupyterHub.admin_access` is True (not default),
|
||||
then admin users have permission to log in *as other users* on their respective machines, for debugging.
|
||||
**You should make sure your users know if admin_access is enabled.**
|
||||
|
||||
### Adding and removing users
|
||||
|
||||
Users can be added and removed to the Hub via the admin panel or REST API. These users will be
|
||||
added to the whitelist and database. Restarting the Hub will not require manually updating the
|
||||
whitelist in your config file, as the users will be loaded from the database. This means that
|
||||
after starting the Hub once, it is not sufficient to remove users from the whitelist in your
|
||||
config file. You must also remove them from the database, either by discarding the database file,
|
||||
or via the admin UI.
|
||||
|
||||
The default `PAMAuthenticator` is one case of a special kind of authenticator, called a
|
||||
`LocalAuthenticator`, indicating that it manages users on the local system. When you add a user to
|
||||
the Hub, a `LocalAuthenticator` checks if that user already exists. Normally, there will be an
|
||||
error telling you that the user doesn't exist. If you set the configuration value
|
||||
|
||||
```python
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
```
|
||||
|
||||
however, adding a user to the Hub that doesn't already exist on the system will result in the Hub
|
||||
creating that user via the system `adduser` command line tool. This option is typically used on
|
||||
hosted deployments of JupyterHub, to avoid the need to manually create all your users before
|
||||
launching the service. It is not recommended when running JupyterHub in situations where
|
||||
JupyterHub users maps directly onto UNIX users.
|
||||
|
||||
## Configuring single-user servers
|
||||
|
||||
Since the single-user server is an instance of `jupyter notebook`, an entire separate
|
||||
multi-process application, there are many aspect of that server can configure, and a lot of ways
|
||||
to express that configuration.
|
||||
|
||||
At the JupyterHub level, you can set some values on the Spawner. The simplest of these is
|
||||
`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root
|
||||
notebook directory is the highest level directory users will be able to access in the notebook
|
||||
dashboard. In this example, the root notebook directory is set to `~/notebooks`, where `~` is
|
||||
expanded to the user's home directory.
|
||||
|
||||
```python
|
||||
c.Spawner.notebook_dir = '~/notebooks'
|
||||
```
|
||||
|
||||
You can also specify extra command-line arguments to the notebook server with:
|
||||
|
||||
```python
|
||||
c.Spawner.args = ['--debug', '--profile=PHYS131']
|
||||
```
|
||||
|
||||
This could be used to set the users default page for the single user server:
|
||||
|
||||
```python
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Since the single-user server extends the notebook server application,
|
||||
it still loads configuration from the `ipython_notebook_config.py` config file.
|
||||
Each user may have one of these files in `$HOME/.ipython/profile_default/`.
|
||||
IPython also supports loading system-wide config files from `/etc/ipython/`,
|
||||
which is the place to put configuration that you want to affect all of your users.
|
||||
|
||||
## External services
|
||||
|
||||
JupyterHub has a REST API that can be used to run external services.
|
||||
More detail on this API will be added in the future.
|
||||
|
||||
## File locations
|
||||
|
||||
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
|
||||
|
||||
* `/srv/jupyterhub` for all security and runtime files
|
||||
* `/etc/jupyterhub` for all configuration files
|
||||
* `/var/log` for log files
|
||||
|
||||
## Example
|
||||
|
||||
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
|
||||
|
||||
* JupyterHub is running on a single cloud server
|
||||
* Using SSL on the standard HTTPS port 443
|
||||
* You want to use [GitHub OAuth][oauthenticator] for login
|
||||
* You need the users to exist locally on the server
|
||||
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
|
||||
other users home directories
|
||||
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
|
||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
c = get_config()
|
||||
|
||||
import os
|
||||
pjoin = os.path.join
|
||||
|
||||
runtime_dir = os.path.join('/srv/jupyterhub')
|
||||
ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
os.makedirs(ssl_dir)
|
||||
|
||||
|
||||
# https on :443
|
||||
c.JupyterHub.port = 443
|
||||
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
|
||||
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
|
||||
|
||||
# put the JupyterHub cookie secret and state db
|
||||
# in /var/run/jupyterhub
|
||||
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
|
||||
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
|
||||
|
||||
# put the log file in /var/log
|
||||
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
|
||||
|
||||
# use GitHub OAuthenticator for local users
|
||||
|
||||
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
|
||||
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
|
||||
# create system users that don't exist yet
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
|
||||
# specify users and admin
|
||||
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
|
||||
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
|
||||
|
||||
# start single-user notebook servers in ~/assignments,
|
||||
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||
# this config could also be put in
|
||||
# /etc/ipython/ipython_notebook_config.py
|
||||
c.Spawner.notebook_dir = '~/assignments'
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
|
||||
which we will need to set when we launch the server:
|
||||
|
||||
```bash
|
||||
export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /path/to/aboveconfig.py
|
||||
```
|
||||
|
||||
# Further reading
|
||||
|
||||
- [Custom Authenticators](./authenticators.html)
|
||||
- [Custom Spawners](./spawners.html)
|
||||
- [Troubleshooting](./troubleshooting.html)
|
||||
|
||||
|
||||
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
||||
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
@@ -5,7 +5,7 @@ JupyterHub is a multi-user server that manages and proxies multiple instances of
|
||||
There are three basic processes involved:
|
||||
|
||||
- multi-user Hub (Python/Tornado)
|
||||
- configurable http proxy (nodejs)
|
||||
- [configurable http proxy](https://github.com/jupyter/configurable-http-proxy) (node-http-proxy)
|
||||
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
|
||||
|
||||
The proxy is the only process that listens on a public interface.
|
||||
@@ -51,7 +51,7 @@ Authentication is customizable via the Authenticator class.
|
||||
Authentication can be replaced by any mechanism,
|
||||
such as OAuth, Kerberos, etc.
|
||||
|
||||
JupyterHub only ships with [PAM](http://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication,
|
||||
JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication,
|
||||
which requires the server to be run as root,
|
||||
or at least with access to the PAM service,
|
||||
which regular users typically do not have
|
BIN
docs/source/images/spawn-form.png
Normal file
BIN
docs/source/images/spawn-form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 55 KiB |
94
docs/source/index.rst
Normal file
94
docs/source/index.rst
Normal file
@@ -0,0 +1,94 @@
|
||||
JupyterHub
|
||||
==========
|
||||
|
||||
JupyterHub is a server that gives multiple users access to Jupyter notebooks,
|
||||
running an independent Jupyter notebook server for each user.
|
||||
|
||||
To use JupyterHub, you need a Unix server (typically Linux) running
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Next, :doc:`spawners <spawners>` control how JupyterHub starts
|
||||
the individual notebook server for each user. The default spawner will
|
||||
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:
|
||||
|
||||
* Hub spawns proxy
|
||||
* Proxy forwards ~all requests to hub by default
|
||||
* Hub handles login, and spawns single-user servers on demand
|
||||
* Hub configures proxy to forward url prefixes to single-user servers
|
||||
|
||||
|
||||
Contents:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Documentation
|
||||
|
||||
getting-started
|
||||
howitworks
|
||||
websecurity
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Configuration
|
||||
|
||||
authenticators
|
||||
spawners
|
||||
troubleshooting
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Developer Documentation
|
||||
|
||||
api/index
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Community documentation
|
||||
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: About JupyterHub
|
||||
|
||||
changelog
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Questions? Suggestions?
|
||||
|
||||
Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>
|
||||
Jupyter website <https://jupyter.org>
|
||||
Stack Overflow - Jupyter <https://stackoverflow.com/questions/tagged/jupyter>
|
||||
Stack Overflow - Jupyter-notebook <https://stackoverflow.com/questions/tagged/jupyter-notebook>
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
160
docs/source/spawners.md
Normal file
160
docs/source/spawners.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Writing a custom Spawner
|
||||
|
||||
A [Spawner][] starts each single-user notebook server.
|
||||
The Spawner represents an abstract interface to a process,
|
||||
and a custom Spawner needs to be able to take three actions:
|
||||
|
||||
- start the process
|
||||
- poll whether the process is still running
|
||||
- stop the process
|
||||
|
||||
## Examples
|
||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners). Some examples include:
|
||||
- [DockerSpawner](https://github.com/jupyter/dockerspawner) for spawning user servers in Docker containers
|
||||
* dockerspawner.DockerSpawner for spawning identical Docker containers for
|
||||
each users
|
||||
* dockerspawner.SystemUserSpawner for spawning Docker containers with an
|
||||
environment and home directory for each users
|
||||
- [SudoSpawner](https://github.com/jupyter/sudospawner) enables JupyterHub to
|
||||
run without being root, by spawning an intermediate process via `sudo`
|
||||
- [BatchSpawner](https://github.com/mbmilligan/batchspawner) for spawning remote
|
||||
servers using batch systems
|
||||
- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks
|
||||
and a remote server and tunnel the port via SSH
|
||||
- [SwarmSpawner](https://github.com/compmodels/jupyterhub/blob/master/swarmspawner.py)
|
||||
for spawning containers using Docker Swarm
|
||||
|
||||
## Spawner control methods
|
||||
|
||||
### Spawner.start
|
||||
|
||||
`Spawner.start` should start the single-user server for a single user.
|
||||
Information about the user can be retrieved from `self.user`,
|
||||
an object encapsulating the user's name, authentication, and server info.
|
||||
|
||||
When `Spawner.start` returns, it should have stored the IP and port
|
||||
of the single-user server in `self.user.server`.
|
||||
|
||||
**NOTE:** When writing coroutines, *never* `yield` in between a database change and a commit.
|
||||
|
||||
Most `Spawner.start` functions will look similar to this example:
|
||||
|
||||
```python
|
||||
def start(self):
|
||||
self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub
|
||||
self.user.server.port = 1234 # port selected somehow
|
||||
self.db.commit() # always commit before yield, if modifying db values
|
||||
yield self._actually_start_server_somehow()
|
||||
```
|
||||
|
||||
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
||||
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
||||
via relaxing the `Spawner.start_timeout` config value.
|
||||
|
||||
### Spawner.poll
|
||||
|
||||
`Spawner.poll` should check if the spawner is still running.
|
||||
It should return `None` if it is still running,
|
||||
and an integer exit status, otherwise.
|
||||
|
||||
For the local process case, `Spawner.poll` uses `os.kill(PID, 0)`
|
||||
to check if the local process is still running.
|
||||
|
||||
|
||||
### Spawner.stop
|
||||
|
||||
`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
||||
|
||||
## Spawner state
|
||||
|
||||
JupyterHub should be able to stop and restart without tearing down
|
||||
single-user notebook servers. To do this task, a Spawner may need to persist
|
||||
some information that can be restored later.
|
||||
A JSON-able dictionary of state can be used to store persisted information.
|
||||
|
||||
Unlike start, stop, and poll methods, the state methods must not be coroutines.
|
||||
|
||||
For the single-process case, the Spawner state is only the process ID of the server:
|
||||
|
||||
```python
|
||||
def get_state(self):
|
||||
"""get the current state"""
|
||||
state = super().get_state()
|
||||
if self.pid:
|
||||
state['pid'] = self.pid
|
||||
return state
|
||||
|
||||
def load_state(self, state):
|
||||
"""load state from the database"""
|
||||
super().load_state(state)
|
||||
if 'pid' in state:
|
||||
self.pid = state['pid']
|
||||
|
||||
def clear_state(self):
|
||||
"""clear any state (called after shutdown)"""
|
||||
super().clear_state()
|
||||
self.pid = 0
|
||||
```
|
||||
|
||||
## Spawner options form
|
||||
|
||||
(new in 0.4)
|
||||
|
||||
Some deployments may want to offer options to users to influence how their servers are started.
|
||||
This may include cluster-based deployments, where users specify what resources should be available,
|
||||
or docker-based deployments where users can select from a list of base images.
|
||||
|
||||
This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet
|
||||
inserted unmodified into the spawn form.
|
||||
If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this:
|
||||
|
||||

|
||||
|
||||
If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered.
|
||||
|
||||
See [this example](https://github.com/jupyter/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner.
|
||||
|
||||
|
||||
### `Spawner.options_from_form`
|
||||
|
||||
Options from this form will always be a dictionary of lists of strings, e.g.:
|
||||
|
||||
```python
|
||||
{
|
||||
'integer': ['5'],
|
||||
'text': ['some text'],
|
||||
'select': ['a', 'b'],
|
||||
}
|
||||
```
|
||||
|
||||
When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`,
|
||||
which is a method to turn the form data into the correct structure.
|
||||
This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types. For example, the `options_from_form` for the above form would look like:
|
||||
|
||||
```python
|
||||
def options_from_form(self, formdata):
|
||||
options = {}
|
||||
options['integer'] = int(formdata['integer'][0]) # single integer value
|
||||
options['text'] = formdata['text'][0] # single string value
|
||||
options['select'] = formdata['select'] # list already correct
|
||||
options['notinform'] = 'extra info' # not in the form at all
|
||||
return options
|
||||
```
|
||||
|
||||
which would return:
|
||||
|
||||
```python
|
||||
{
|
||||
'integer': 5,
|
||||
'text': 'some text',
|
||||
'select': ['a', 'b'],
|
||||
'notinform': 'extra info',
|
||||
}
|
||||
```
|
||||
|
||||
When `Spawner.spawn` is called, this dictionary is accessible as `self.user_options`.
|
||||
|
||||
|
||||
|
||||
[Spawner]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/spawner.py
|
99
docs/source/troubleshooting.md
Normal file
99
docs/source/troubleshooting.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Troubleshooting
|
||||
|
||||
This document is under active development.
|
||||
|
||||
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.
|
||||
|
||||
## Behavior problems
|
||||
- [JupyterHub proxy fails to start](#jupyterhub-proxy-fails-to-start)
|
||||
|
||||
## Errors
|
||||
- [500 error after spawning a single-user server](#500-error-after-spawning-my-single-user-server)
|
||||
|
||||
----
|
||||
|
||||
## JupyterHub proxy fails to start
|
||||
|
||||
If you have tried to start the JupyterHub proxy and it fails to start:
|
||||
|
||||
- check if the JupyterHub IP configuration setting is
|
||||
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
|
||||
- Try starting with ``jupyterhub --ip=0.0.0.0``
|
||||
|
||||
----
|
||||
|
||||
## 500 error after spawning my single-user server
|
||||
|
||||
|
||||
You receive a 500 error when accessing the URL `/user/you/...`. This is often
|
||||
seen when your single-user server cannot check your cookies with the Hub.
|
||||
|
||||
There are two likely reasons for this:
|
||||
|
||||
1. The single-user server cannot connect to the Hub's API (networking
|
||||
configuration problems)
|
||||
2. The single-user server cannot *authenticate* its requests (invalid token)
|
||||
|
||||
### Symptoms:
|
||||
|
||||
The main symptom is a failure to load *any* page served by the single-user
|
||||
server, met with a 500 error. This is typically the first page at `/user/you`
|
||||
after logging in or clicking "Start my server". When a single-user server
|
||||
receives a request, it makes an API request to the Hub to check if the cookie
|
||||
corresponds to the right user. This request is logged.
|
||||
|
||||
If everything is working, it will look like this:
|
||||
|
||||
```
|
||||
200 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 6.10ms
|
||||
```
|
||||
|
||||
You should see a similar 200 message, as above, in the Hub log when you first
|
||||
visit your single-user server. If you don't see this message in the log, it
|
||||
may mean that your single-user server isn't connecting to your Hub.
|
||||
|
||||
If you see 403 (forbidden) like this, it's a token problem:
|
||||
|
||||
```
|
||||
403 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 4.14ms
|
||||
```
|
||||
|
||||
Check the logs of the single-user server, which may have more detailed
|
||||
information on the cause.
|
||||
|
||||
### Causes and resolutions:
|
||||
|
||||
#### No authorization request
|
||||
|
||||
If you make an API request and it is not received by the server, you likely
|
||||
have a network configuration issue. Often, this happens when the Hub is only
|
||||
listening on 127.0.0.1 (default) and the single-user servers are not on the
|
||||
same 'machine' (can be physically remote, or in a docker container or VM). The
|
||||
fix for this case is to make sure that `c.JupyterHub.hub_ip` is an address
|
||||
that all single-user servers can connect to, e.g.:
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_ip = '10.0.0.1'
|
||||
```
|
||||
|
||||
#### 403 GET /hub/api/authorizations/cookie
|
||||
|
||||
If you receive a 403 error, the API token for the single-user server is likely
|
||||
invalid. Commonly, the 403 error is caused by resetting the JupyterHub
|
||||
database (either removing jupyterhub.sqlite or some other action) while
|
||||
leaving single-user servers running. This happens most frequently when using
|
||||
DockerSpawner, because Docker's default behavior is to stop/start containers
|
||||
which resets the JupyterHub database, rather than destroying and recreating
|
||||
the container every time. This means that the same API token is used by the
|
||||
server for its whole life, until the container is rebuilt.
|
||||
|
||||
The fix for this Docker case is to remove any Docker containers seeing this
|
||||
issue (typicaly all containers created before a certain point in time):
|
||||
|
||||
docker rm -f jupyter-name
|
||||
|
||||
After this, when you start your server via JupyterHub, it will build a
|
||||
new container. If this was the underlying cause of the issue, you should see
|
||||
your server again.
|
63
docs/source/websecurity.md
Normal file
63
docs/source/websecurity.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Web Security in JupyterHub
|
||||
|
||||
JupyterHub is designed to be a simple multi-user server for modestly sized groups of semi-trusted users.
|
||||
While the design reflects serving semi-trusted users,
|
||||
JupyterHub is not necessarily unsuitable for serving untrusted users.
|
||||
Using JupyterHub with untrusted users does mean more work and much care is required to secure a Hub against untrusted users,
|
||||
with extra caution on protecting users from each other as the Hub is serving untrusted users.
|
||||
|
||||
One aspect of JupyterHub's design simplicity for semi-trusted users is that the Hub and single-user servers are placed in a single domain, behind a [proxy][configurable-http-proxy].
|
||||
As a result, if the Hub is serving untrusted users,
|
||||
many of the web's cross-site protections are not applied between single-user servers and the Hub,
|
||||
or between single-user servers and each other,
|
||||
since browsers see the whole thing (proxy, Hub, and single user servers) as a single website.
|
||||
|
||||
To protect users from each other, a user must never be able to write arbitrary HTML and serve it to another user on the Hub's domain.
|
||||
JupyterHub's authentication setup prevents this because only the owner of a given single-user server is allowed to view user-authored pages served by their server.
|
||||
To protect all users from each other, JupyterHub administrators must ensure that:
|
||||
|
||||
* A user does not have permission to modify their single-user server:
|
||||
- A user may not install new packages in the Python environment that runs their server.
|
||||
- If the PATH is used to resolve the single-user executable (instead of an absolute path), a user may not create new files in any PATH directory that precedes the directory containing jupyterhub-singleuser.
|
||||
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for their single-user server.
|
||||
* A user may not modify the configuration of the notebook server (the ~/.jupyter or JUPYTER_CONFIG_DIR directory).
|
||||
|
||||
If any additional services are run on the same domain as the Hub, the services must never display user-authored HTML that is neither sanitized nor sandboxed (e.g. IFramed) to any user that lacks authentication as the author of a file.
|
||||
|
||||
|
||||
## Mitigations
|
||||
|
||||
There are two main configuration options provided by JupyterHub to mitigate these issues:
|
||||
|
||||
### Subdomains
|
||||
|
||||
JupyterHub 0.5 adds the ability to run single-user servers on their own subdomains,
|
||||
which means the cross-origin protections between servers has the desired effect,
|
||||
and user servers and the Hub are protected from each other.
|
||||
A user's server will be at `username.jupyter.mydomain.com`, etc.
|
||||
This requires all user subdomains to point to the same address,
|
||||
which is most easily accomplished with wildcard DNS.
|
||||
Since this spreads the service across multiple domains, you will need wildcard SSL, as well.
|
||||
Unfortunately, for many institutional domains, wildcard DNS and SSL are not available,
|
||||
but if you do plan to serve untrusted users, enabling subdomains is highly encouraged,
|
||||
as it resolves all of the cross-site issues.
|
||||
|
||||
### Disabling user config
|
||||
|
||||
If subdomains are not available or not desirable,
|
||||
0.5 also adds an option `Spawner.disable_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,89 +0,0 @@
|
||||
# Writing a custom Spawner
|
||||
|
||||
Each single-user server is started by a [Spawner][].
|
||||
The Spawner represents an abstract interface to a process,
|
||||
and a custom Spawner needs to be able to take three actions:
|
||||
|
||||
1. start the process
|
||||
2. poll whether the process is still running
|
||||
3. stop the process
|
||||
|
||||
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
||||
|
||||
|
||||
## Spawner.start
|
||||
|
||||
`Spawner.start` should start the single-user server for a single user.
|
||||
Information about the user can be retrieved from `self.user`,
|
||||
an object encapsulating the user's name, authentication, and server info.
|
||||
|
||||
When `Spawner.start` returns, it should have stored the IP and port
|
||||
of the single-user server in `self.user.server`.
|
||||
|
||||
**NOTE:** when writing coroutines, *never* `yield` in between a db change and a commit.
|
||||
Most `Spawner.start`s should have something looking like:
|
||||
|
||||
```python
|
||||
def start(self):
|
||||
self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub
|
||||
self.user.server.port = 1234 # port selected somehow
|
||||
self.db.commit() # always commit before yield, if modifying db values
|
||||
yield self._actually_start_server_somehow()
|
||||
```
|
||||
|
||||
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
||||
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
||||
via relaxing the `Spawner.start_timeout` config value.
|
||||
|
||||
|
||||
## Spawner.poll
|
||||
|
||||
`Spawner.poll` should check if the spawner is still running.
|
||||
It should return `None` if it is still running,
|
||||
and an integer exit status, otherwise.
|
||||
|
||||
For the local process case, this uses `os.kill(PID, 0)`
|
||||
to check if the process is still around.
|
||||
|
||||
|
||||
## Spawner.stop
|
||||
|
||||
`Spawner.stop` should stop the process. It must be a tornado coroutine,
|
||||
and should return when the process has finished exiting.
|
||||
|
||||
|
||||
## Spawner state
|
||||
|
||||
JupyterHub should be able to stop and restart without having to teardown
|
||||
single-user servers. This means that a Spawner may need to persist
|
||||
some information that it can be restored.
|
||||
A dictionary of JSON-able state can be used to store this information.
|
||||
|
||||
Unlike start/stop/poll, the state methods must not be coroutines.
|
||||
|
||||
In the single-process case, this is only the process ID of the server:
|
||||
|
||||
```python
|
||||
def get_state(self):
|
||||
"""get the current state"""
|
||||
state = super().get_state()
|
||||
if self.pid:
|
||||
state['pid'] = self.pid
|
||||
return state
|
||||
|
||||
def load_state(self, state):
|
||||
"""load state from the database"""
|
||||
super().load_state(state)
|
||||
if 'pid' in state:
|
||||
self.pid = state['pid']
|
||||
|
||||
def clear_state(self):
|
||||
"""clear any state (called after shutdown)"""
|
||||
super().clear_state()
|
||||
self.pid = 0
|
||||
```
|
||||
|
||||
|
||||
|
||||
[Spawner]: ../jupyterhub/spawner.py
|
@@ -1,4 +1,4 @@
|
||||
FROM jupyter/jupyterhub
|
||||
FROM jupyter/jupyterhub-onbuild
|
||||
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
|
46
examples/spawn-form/jupyterhub_config.py
Normal file
46
examples/spawn-form/jupyterhub_config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Example JupyterHub config allowing users to specify environment variables and notebook-server args
|
||||
"""
|
||||
import shlex
|
||||
|
||||
from jupyterhub.spawner import LocalProcessSpawner
|
||||
|
||||
class DemoFormSpawner(LocalProcessSpawner):
|
||||
def _options_form_default(self):
|
||||
default_env = "YOURNAME=%s\n" % self.user.name
|
||||
return """
|
||||
<label for="args">Extra notebook CLI arguments</label>
|
||||
<input name="args" placeholder="e.g. --debug"></input>
|
||||
<label for="env">Environment variables (one per line)</label>
|
||||
<textarea name="env">{env}</textarea>
|
||||
""".format(env=default_env)
|
||||
|
||||
def options_from_form(self, formdata):
|
||||
options = {}
|
||||
options['env'] = env = {}
|
||||
|
||||
env_lines = formdata.get('env', [''])
|
||||
for line in env_lines[0].splitlines():
|
||||
if line:
|
||||
key, value = line.split('=', 1)
|
||||
env[key.strip()] = value.strip()
|
||||
|
||||
arg_s = formdata.get('args', [''])[0].strip()
|
||||
if arg_s:
|
||||
options['argv'] = shlex.split(arg_s)
|
||||
return options
|
||||
|
||||
def get_args(self):
|
||||
"""Return arguments to pass to the notebook server"""
|
||||
argv = super().get_args()
|
||||
if self.user_options.get('argv'):
|
||||
argv.extend(self.user_options['argv'])
|
||||
return argv
|
||||
|
||||
def get_env(self):
|
||||
env = super().get_env()
|
||||
if self.user_options.get('env'):
|
||||
env.update(self.user_options['env'])
|
||||
return env
|
||||
|
||||
c.JupyterHub.spawner_class = DemoFormSpawner
|
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from tornado import web
|
||||
from tornado import web, gen
|
||||
from .. import orm
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
@@ -18,15 +18,27 @@ class TokenAPIHandler(APIHandler):
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps(self.user_model(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):
|
||||
@token_authenticated
|
||||
def get(self, cookie_name, cookie_value=None):
|
||||
cookie_name = quote(cookie_name, safe='')
|
||||
if cookie_value is None:
|
||||
self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
|
||||
self.log.warning("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
|
||||
cookie_value = self.request.body
|
||||
else:
|
||||
cookie_value = cookie_value.encode('utf8')
|
||||
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
(r"/api/authorizations/token", TokenAPIHandler),
|
||||
]
|
||||
|
@@ -26,25 +26,29 @@ class APIHandler(BaseHandler):
|
||||
# If no header is provided, assume it comes from a script/curl.
|
||||
# We are only concerned with cross-site browser stuff here.
|
||||
if not host:
|
||||
self.log.warn("Blocking API request with no host")
|
||||
self.log.warning("Blocking API request with no host")
|
||||
return False
|
||||
if not referer:
|
||||
self.log.warn("Blocking API request with no referer")
|
||||
self.log.warning("Blocking API request with no referer")
|
||||
return False
|
||||
|
||||
host_path = url_path_join(host, self.hub.server.base_url)
|
||||
referer_path = referer.split('://', 1)[-1]
|
||||
if not (referer_path + '/').startswith(host_path):
|
||||
self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
referer, host_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""Override get_user_cookie to check Referer header"""
|
||||
if not self.check_referer():
|
||||
cookie_user = super().get_current_user_cookie()
|
||||
# check referer only if there is a cookie user,
|
||||
# avoiding misleading "Blocking Cross Origin" messages
|
||||
# when there's no cookie set anyway.
|
||||
if cookie_user and not self.check_referer():
|
||||
return None
|
||||
return super().get_current_user_cookie()
|
||||
return cookie_user
|
||||
|
||||
def get_json_body(self):
|
||||
"""Return the body of the request as JSON data."""
|
||||
@@ -86,7 +90,7 @@ class APIHandler(BaseHandler):
|
||||
model = {
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'server': user.server.base_url if user.running else None,
|
||||
'server': user.url if user.running else None,
|
||||
'pending': None,
|
||||
'last_activity': user.last_activity.isoformat(),
|
||||
}
|
||||
|
@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
"""POST checks the proxy to ensure"""
|
||||
yield self.proxy.check_routes()
|
||||
yield self.proxy.check_routes(self.users)
|
||||
|
||||
|
||||
@admin_only
|
||||
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
self.proxy.auth_token = model['auth_token']
|
||||
self.db.commit()
|
||||
self.log.info("Updated proxy at %s", server.bind_url)
|
||||
yield self.proxy.check_routes()
|
||||
yield self.proxy.check_routes(self.users)
|
||||
|
||||
|
||||
|
||||
|
@@ -15,7 +15,7 @@ from .base import APIHandler
|
||||
class UserListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
users = self.db.query(orm.User)
|
||||
users = [ self._user_from_orm(u) for u in self.db.query(orm.User) ]
|
||||
data = [ self.user_model(u) for u in users ]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@@ -33,13 +33,25 @@ class UserListAPIHandler(APIHandler):
|
||||
admin = data.get('admin', False)
|
||||
|
||||
to_create = []
|
||||
invalid_names = []
|
||||
for name in usernames:
|
||||
name = self.authenticator.normalize_username(name)
|
||||
if not self.authenticator.validate_username(name):
|
||||
invalid_names.append(name)
|
||||
continue
|
||||
user = self.find_user(name)
|
||||
if user is not None:
|
||||
self.log.warn("User %s already exists" % name)
|
||||
self.log.warning("User %s already exists" % name)
|
||||
else:
|
||||
to_create.append(name)
|
||||
|
||||
if invalid_names:
|
||||
if len(invalid_names) == 1:
|
||||
msg = "Invalid username: %s" % invalid_names[0]
|
||||
else:
|
||||
msg = "Invalid usernames: %s" % ', '.join(invalid_names)
|
||||
raise web.HTTPError(400, msg)
|
||||
|
||||
if not to_create:
|
||||
raise web.HTTPError(400, "All %i users already exist" % len(usernames))
|
||||
|
||||
@@ -51,11 +63,10 @@ class UserListAPIHandler(APIHandler):
|
||||
self.db.commit()
|
||||
try:
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
||||
del self.users[user]
|
||||
raise web.HTTPError(400, "Failed to create user %s: %s" % (name, str(e)))
|
||||
else:
|
||||
created.append(user)
|
||||
|
||||
@@ -104,8 +115,8 @@ class UserAPIHandler(APIHandler):
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
except Exception:
|
||||
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
# remove from registry
|
||||
del self.users[user]
|
||||
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
||||
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
@@ -127,10 +138,8 @@ class UserAPIHandler(APIHandler):
|
||||
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
||||
|
||||
yield gen.maybe_future(self.authenticator.delete_user(user))
|
||||
|
||||
# remove from the db
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
# remove from registry
|
||||
del self.users[user]
|
||||
|
||||
self.set_status(204)
|
||||
|
||||
@@ -152,12 +161,14 @@ class UserServerAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
def post(self, name):
|
||||
user = self.find_user(name)
|
||||
if user.spawner:
|
||||
state = yield user.spawner.poll()
|
||||
if user.running:
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
state = yield user.spawner.poll_and_notify()
|
||||
if state is None:
|
||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||
|
||||
yield self.spawn_single_user(user)
|
||||
options = self.get_json_body()
|
||||
yield self.spawn_single_user(user, options=options)
|
||||
status = 202 if user.spawn_pending else 201
|
||||
self.set_status(status)
|
||||
|
||||
@@ -170,7 +181,8 @@ class UserServerAPIHandler(APIHandler):
|
||||
return
|
||||
if not user.running:
|
||||
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:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
yield self.stop_single_user(user)
|
||||
@@ -185,7 +197,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
current = self.get_current_user()
|
||||
self.log.warn("Admin user %s has requested access to %s's server",
|
||||
self.log.warning("Admin user %s has requested access to %s's server",
|
||||
current.name, name,
|
||||
)
|
||||
if not self.settings.get('admin_access', False):
|
||||
@@ -196,6 +208,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
if not user.running:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
self.set_server_cookie(user)
|
||||
current.other_user_cookies.add(name)
|
||||
|
||||
|
||||
default_handlers = [
|
||||
|
@@ -16,6 +16,7 @@ from datetime import datetime
|
||||
from distutils.version import LooseVersion as V
|
||||
from getpass import getuser
|
||||
from subprocess import Popen
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if sys.version_info[:2] < (3,3):
|
||||
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
|
||||
@@ -35,6 +36,7 @@ from tornado import gen, web
|
||||
from traitlets import (
|
||||
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
||||
Type, Set, Instance, Bytes, Float,
|
||||
observe, default,
|
||||
)
|
||||
from traitlets.config import Application, catch_config_error
|
||||
|
||||
@@ -42,9 +44,10 @@ here = os.path.dirname(__file__)
|
||||
|
||||
import jupyterhub
|
||||
from . import handlers, apihandlers
|
||||
from .handlers.static import CacheControlStaticFilesHandler
|
||||
from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
|
||||
|
||||
from . import orm
|
||||
from .user import User, UserDict
|
||||
from ._data import DATA_FILES_PATH
|
||||
from .log import CoroutineLogFormatter, log_request
|
||||
from .traitlets import URLPrefix, Command
|
||||
@@ -56,6 +59,10 @@ from .utils import (
|
||||
from .auth import Authenticator, PAMAuthenticator
|
||||
from .spawner import Spawner, LocalProcessSpawner
|
||||
|
||||
# For faking stats
|
||||
from .emptyclass import EmptyClass
|
||||
|
||||
|
||||
common_aliases = {
|
||||
'log-level': 'Application.log_level',
|
||||
'f': 'JupyterHub.config_file',
|
||||
@@ -86,6 +93,9 @@ flags = {
|
||||
'no-db': ({'JupyterHub': {'db_url': 'sqlite:///:memory:'}},
|
||||
"disable persisting state database to disk"
|
||||
),
|
||||
'no-ssl': ({'JupyterHub': {'confirm_no_ssl': True}},
|
||||
"Allow JupyterHub to run without SSL (SSL termination should be happening elsewhere)."
|
||||
),
|
||||
}
|
||||
|
||||
SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets
|
||||
@@ -170,87 +180,124 @@ class JupyterHub(Application):
|
||||
PAMAuthenticator,
|
||||
])
|
||||
|
||||
config_file = Unicode('jupyterhub_config.py', config=True,
|
||||
config_file = Unicode('jupyterhub_config.py',
|
||||
help="The config file to load",
|
||||
)
|
||||
generate_config = Bool(False, config=True,
|
||||
).tag(config=True)
|
||||
generate_config = Bool(False,
|
||||
help="Generate default config file",
|
||||
)
|
||||
answer_yes = Bool(False, config=True,
|
||||
).tag(config=True)
|
||||
answer_yes = Bool(False,
|
||||
help="Answer yes to any questions (e.g. confirm overwrite)"
|
||||
)
|
||||
pid_file = Unicode('', config=True,
|
||||
).tag(config=True)
|
||||
pid_file = Unicode('',
|
||||
help="""File to write PID
|
||||
Useful for daemonizing jupyterhub.
|
||||
"""
|
||||
)
|
||||
cookie_max_age_days = Float(14, config=True,
|
||||
).tag(config=True)
|
||||
cookie_max_age_days = Float(14,
|
||||
help="""Number of days for a login cookie to be valid.
|
||||
Default is two weeks.
|
||||
"""
|
||||
)
|
||||
last_activity_interval = Integer(300, config=True,
|
||||
).tag(config=True)
|
||||
last_activity_interval = Integer(300,
|
||||
help="Interval (in seconds) at which to update last-activity timestamps."
|
||||
)
|
||||
proxy_check_interval = Integer(30, config=True,
|
||||
).tag(config=True)
|
||||
proxy_check_interval = Integer(30,
|
||||
help="Interval (in seconds) at which to check if the proxy is running."
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
data_files_path = Unicode(DATA_FILES_PATH, config=True,
|
||||
data_files_path = Unicode(DATA_FILES_PATH,
|
||||
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
template_paths = List(
|
||||
config=True,
|
||||
help="Paths to search for jinja templates.",
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
@default('template_paths')
|
||||
def _template_paths_default(self):
|
||||
return [os.path.join(self.data_files_path, 'templates')]
|
||||
|
||||
ssl_key = Unicode('', config=True,
|
||||
confirm_no_ssl = Bool(False,
|
||||
help="""Confirm that JupyterHub should be run without SSL.
|
||||
This is **NOT RECOMMENDED** unless SSL termination is being handled by another layer.
|
||||
"""
|
||||
).tag(config=True)
|
||||
ssl_key = Unicode('',
|
||||
help="""Path to SSL key file for the public facing interface of the proxy
|
||||
|
||||
Use with ssl_cert
|
||||
"""
|
||||
)
|
||||
ssl_cert = Unicode('', config=True,
|
||||
).tag(config=True)
|
||||
ssl_cert = Unicode('',
|
||||
help="""Path to SSL certificate file for the public facing interface of the proxy
|
||||
|
||||
Use with ssl_key
|
||||
"""
|
||||
)
|
||||
ip = Unicode('', config=True,
|
||||
help="The public facing ip of the proxy"
|
||||
)
|
||||
port = Integer(8000, config=True,
|
||||
).tag(config=True)
|
||||
ip = Unicode('',
|
||||
help="The public facing ip of the whole application (the proxy)"
|
||||
).tag(config=True)
|
||||
|
||||
subdomain_host = Unicode('',
|
||||
help="""Run single-user servers on subdomains of this host.
|
||||
|
||||
This should be the full https://hub.domain.tld[:port]
|
||||
|
||||
Provides additional cross-site protections for javascript served by single-user servers.
|
||||
|
||||
Requires <username>.hub.domain.tld to resolve to the same host as hub.domain.tld.
|
||||
|
||||
In general, this is most easily achieved with wildcard DNS.
|
||||
|
||||
When using SSL (i.e. always) this also requires a wildcard SSL certificate.
|
||||
"""
|
||||
).tag(config=True)
|
||||
def _subdomain_host_changed(self, name, old, new):
|
||||
if new and '://' not in new:
|
||||
# host should include '://'
|
||||
# if not specified, assume https: You have to be really explicit about HTTP!
|
||||
self.subdomain_host = 'https://' + new
|
||||
|
||||
port = Integer(8000,
|
||||
help="The public facing port of the proxy"
|
||||
)
|
||||
base_url = URLPrefix('/', config=True,
|
||||
).tag(config=True)
|
||||
base_url = URLPrefix('/',
|
||||
help="The base URL of the entire application"
|
||||
)
|
||||
).tag(config=True)
|
||||
logo_file = Unicode('',
|
||||
help="Specify path to a logo image to override the Jupyter logo in the banner."
|
||||
).tag(config=True)
|
||||
|
||||
jinja_environment_options = Dict(config=True,
|
||||
@default('logo_file')
|
||||
def _logo_file_default(self):
|
||||
return os.path.join(self.data_files_path, 'static', 'images', 'jupyter.png')
|
||||
|
||||
jinja_environment_options = Dict(
|
||||
help="Supply extra arguments that will be passed to Jinja environment."
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
proxy_cmd = Command('configurable-http-proxy', config=True,
|
||||
proxy_cmd = Command('configurable-http-proxy',
|
||||
help="""The command to start the http proxy.
|
||||
|
||||
Only override if configurable-http-proxy is not on your PATH
|
||||
"""
|
||||
)
|
||||
debug_proxy = Bool(False, config=True, help="show debug output in configurable-http-proxy")
|
||||
proxy_auth_token = Unicode(config=True,
|
||||
).tag(config=True)
|
||||
debug_proxy = Bool(False,
|
||||
help="show debug output in configurable-http-proxy"
|
||||
).tag(config=True)
|
||||
proxy_auth_token = Unicode(
|
||||
help="""The Proxy Auth token.
|
||||
|
||||
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
@default('proxy_auth_token')
|
||||
def _proxy_auth_token_default(self):
|
||||
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None)
|
||||
if not token:
|
||||
self.log.warn('\n'.join([
|
||||
self.log.warning('\n'.join([
|
||||
"",
|
||||
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.",
|
||||
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHub.proxy_auth_token config to avoid this message.",
|
||||
@@ -259,47 +306,60 @@ class JupyterHub(Application):
|
||||
token = orm.new_token()
|
||||
return token
|
||||
|
||||
proxy_api_ip = Unicode('localhost', config=True,
|
||||
proxy_api_ip = Unicode('127.0.0.1',
|
||||
help="The ip for the proxy API handlers"
|
||||
)
|
||||
proxy_api_port = Integer(config=True,
|
||||
).tag(config=True)
|
||||
proxy_api_port = Integer(
|
||||
help="The port for the proxy API handlers"
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
@default('proxy_api_port')
|
||||
def _proxy_api_port_default(self):
|
||||
return self.port + 1
|
||||
|
||||
hub_port = Integer(8081, config=True,
|
||||
hub_port = Integer(8081,
|
||||
help="The port for this process"
|
||||
)
|
||||
hub_ip = Unicode('localhost', config=True,
|
||||
).tag(config=True)
|
||||
hub_ip = Unicode('127.0.0.1',
|
||||
help="The ip for this process"
|
||||
)
|
||||
|
||||
hub_prefix = URLPrefix('/hub/', config=True,
|
||||
).tag(config=True)
|
||||
hub_prefix = URLPrefix('/hub/',
|
||||
help="The prefix for the hub server. Must not be '/'"
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
@default('hub_prefix')
|
||||
def _hub_prefix_default(self):
|
||||
return url_path_join(self.base_url, '/hub/')
|
||||
|
||||
@observe('hub_prefix')
|
||||
def _hub_prefix_changed(self, name, old, new):
|
||||
if new == '/':
|
||||
raise TraitError("'/' is not a valid hub prefix")
|
||||
if not new.startswith(self.base_url):
|
||||
self.hub_prefix = url_path_join(self.base_url, new)
|
||||
|
||||
cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET',
|
||||
cookie_secret = Bytes(
|
||||
help="""The cookie secret to use to encrypt cookies.
|
||||
|
||||
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
||||
"""
|
||||
).tag(
|
||||
config=True,
|
||||
env='JPY_COOKIE_SECRET',
|
||||
)
|
||||
|
||||
cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True,
|
||||
cookie_secret_file = Unicode('jupyterhub_cookie_secret',
|
||||
help="""File in which to store the cookie secret."""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
api_tokens = Dict(Unicode(),
|
||||
help="""Dict of token:username to be loaded into the database.
|
||||
|
||||
Allows ahead-of-time generation of API tokens for use by services.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
||||
config=True,
|
||||
help="""Class for authenticating users.
|
||||
|
||||
This should be a class with the following form:
|
||||
@@ -312,56 +372,69 @@ class JupyterHub(Application):
|
||||
where `handler` is the calling web.RequestHandler,
|
||||
and `data` is the POST form data from the login page.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
authenticator = Instance(Authenticator)
|
||||
|
||||
@default('authenticator')
|
||||
def _authenticator_default(self):
|
||||
return self.authenticator_class(parent=self, db=self.db)
|
||||
|
||||
# class for spawning single-user servers
|
||||
spawner_class = Type(LocalProcessSpawner, Spawner,
|
||||
config=True,
|
||||
help="""The class to use for spawning single-user servers.
|
||||
|
||||
Should be a subclass of Spawner.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
db_url = Unicode('sqlite:///jupyterhub.sqlite', config=True,
|
||||
db_url = Unicode('sqlite:///jupyterhub.sqlite',
|
||||
help="url for the database. e.g. `sqlite:///jupyterhub.sqlite`"
|
||||
)
|
||||
def _db_url_changed(self, name, old, new):
|
||||
).tag(config=True)
|
||||
|
||||
@observe('db_url')
|
||||
def _db_url_changed(self, change):
|
||||
new = change['new']
|
||||
if '://' not in new:
|
||||
# assume sqlite, if given as a plain filename
|
||||
self.db_url = 'sqlite:///%s' % new
|
||||
|
||||
db_kwargs = Dict(config=True,
|
||||
db_kwargs = Dict(
|
||||
help="""Include any kwargs to pass to the database connection.
|
||||
See sqlalchemy.create_engine for details.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
reset_db = Bool(False, config=True,
|
||||
reset_db = Bool(False,
|
||||
help="Purge and reset the database."
|
||||
)
|
||||
debug_db = Bool(False, config=True,
|
||||
).tag(config=True)
|
||||
debug_db = Bool(False,
|
||||
help="log all database transactions. This has A LOT of output"
|
||||
)
|
||||
).tag(config=True)
|
||||
session_factory = Any()
|
||||
|
||||
admin_access = Bool(False, config=True,
|
||||
users = Instance(UserDict)
|
||||
|
||||
@default('users')
|
||||
def _users_default(self):
|
||||
assert self.tornado_settings
|
||||
return UserDict(db_factory=lambda : self.db, settings=self.tornado_settings)
|
||||
|
||||
admin_access = Bool(False,
|
||||
help="""Grant admin users permission to access single-user servers.
|
||||
|
||||
Users should be properly informed if this is enabled.
|
||||
"""
|
||||
)
|
||||
admin_users = Set(config=True,
|
||||
).tag(config=True)
|
||||
admin_users = Set(
|
||||
help="""DEPRECATED, use Authenticator.admin_users instead."""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
tornado_settings = Dict(config=True)
|
||||
tornado_settings = Dict(
|
||||
help="Extra settings overrides to pass to the tornado application."
|
||||
).tag(config=True)
|
||||
|
||||
cleanup_servers = Bool(True, config=True,
|
||||
cleanup_servers = Bool(True,
|
||||
help="""Whether to shutdown single-user servers when the Hub shuts down.
|
||||
|
||||
Disable if you want to be able to teardown the Hub while leaving the single-user servers running.
|
||||
@@ -371,9 +444,9 @@ class JupyterHub(Application):
|
||||
|
||||
The Hub should be able to resume from database state.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
cleanup_proxy = Bool(True, config=True,
|
||||
cleanup_proxy = Bool(True,
|
||||
help="""Whether to shutdown the proxy when the Hub shuts down.
|
||||
|
||||
Disable if you want to be able to teardown the Hub while leaving the proxy running.
|
||||
@@ -385,7 +458,21 @@ class JupyterHub(Application):
|
||||
|
||||
The Hub should be able to resume from database state.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
statsd_host = Unicode(
|
||||
help="Host to send statds metrics to"
|
||||
).tag(config=True)
|
||||
|
||||
statsd_port = Integer(
|
||||
8125,
|
||||
help="Port on which to send statsd metrics about the hub"
|
||||
).tag(config=True)
|
||||
|
||||
statsd_prefix = Unicode(
|
||||
'jupyterhub',
|
||||
help="Prefix to use for all metrics sent by jupyterhub to statsd"
|
||||
).tag(config=True)
|
||||
|
||||
handlers = List()
|
||||
|
||||
@@ -394,27 +481,46 @@ class JupyterHub(Application):
|
||||
proxy_process = None
|
||||
io_loop = None
|
||||
|
||||
@default('log_level')
|
||||
def _log_level_default(self):
|
||||
return logging.INFO
|
||||
|
||||
@default('log_datefmt')
|
||||
def _log_datefmt_default(self):
|
||||
"""Exclude date from default date format"""
|
||||
return "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
@default('log_format')
|
||||
def _log_format_default(self):
|
||||
"""override default log format to include time"""
|
||||
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
||||
|
||||
extra_log_file = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="Set a logging.FileHandler on this file."
|
||||
)
|
||||
help="""Send JupyterHub's logs to this file.
|
||||
|
||||
This will *only* include the logs of the Hub itself,
|
||||
not the logs of the proxy or any single-user servers.
|
||||
"""
|
||||
).tag(config=True)
|
||||
extra_log_handlers = List(
|
||||
Instance(logging.Handler),
|
||||
config=True,
|
||||
help="Extra log handlers to set on JupyterHub logger",
|
||||
).tag(config=True)
|
||||
|
||||
statsd = Any(allow_none=False, help="The statsd client, if any. A mock will be used if we aren't using statsd")
|
||||
@default('statsd')
|
||||
def _statsd(self):
|
||||
if self.statsd_host:
|
||||
import statsd
|
||||
client = statsd.StatsClient(
|
||||
self.statsd_host,
|
||||
self.statsd_port,
|
||||
self.statsd_prefix
|
||||
)
|
||||
return client
|
||||
else:
|
||||
# return an empty mock object!
|
||||
return EmptyClass()
|
||||
|
||||
def init_logging(self):
|
||||
# This prevents double log messages because tornado use a root logger that
|
||||
@@ -464,13 +570,14 @@ class JupyterHub(Application):
|
||||
|
||||
def init_handlers(self):
|
||||
h = []
|
||||
h.extend(handlers.default_handlers)
|
||||
h.extend(apihandlers.default_handlers)
|
||||
# load handlers from the authenticator
|
||||
h.extend(self.authenticator.get_handlers(self))
|
||||
# set default handlers
|
||||
h.extend(handlers.default_handlers)
|
||||
h.extend(apihandlers.default_handlers)
|
||||
|
||||
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
|
||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||
|
||||
# some extra handlers, outside hub_prefix
|
||||
self.handlers.extend([
|
||||
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
|
||||
@@ -498,30 +605,34 @@ class JupyterHub(Application):
|
||||
def init_secrets(self):
|
||||
trait_name = 'cookie_secret'
|
||||
trait = self.traits()[trait_name]
|
||||
env_name = trait.get_metadata('env')
|
||||
env_name = trait.metadata.get('env')
|
||||
secret_file = os.path.abspath(
|
||||
os.path.expanduser(self.cookie_secret_file)
|
||||
)
|
||||
secret = self.cookie_secret
|
||||
secret_from = 'config'
|
||||
# load priority: 1. config, 2. env, 3. file
|
||||
if not secret and os.environ.get(env_name):
|
||||
secret_env = os.environ.get(env_name)
|
||||
if not secret and secret_env:
|
||||
secret_from = 'env'
|
||||
self.log.info("Loading %s from env[%s]", trait_name, env_name)
|
||||
secret = binascii.a2b_hex(os.environ[env_name])
|
||||
secret = binascii.a2b_hex(secret_env)
|
||||
if not secret and os.path.exists(secret_file):
|
||||
secret_from = 'file'
|
||||
perm = os.stat(secret_file).st_mode
|
||||
if perm & 0o077:
|
||||
self.log.error("Bad permissions on %s", secret_file)
|
||||
else:
|
||||
self.log.info("Loading %s from %s", trait_name, secret_file)
|
||||
try:
|
||||
perm = os.stat(secret_file).st_mode
|
||||
if perm & 0o07:
|
||||
raise ValueError("cookie_secret_file can be read or written by anybody")
|
||||
with open(secret_file) as f:
|
||||
b64_secret = f.read()
|
||||
try:
|
||||
secret = binascii.a2b_base64(b64_secret)
|
||||
except Exception as e:
|
||||
self.log.error("%s does not contain b64 key: %s", secret_file, e)
|
||||
self.log.error(
|
||||
"Refusing to run JupyterHub with invalid cookie_secret_file. "
|
||||
"%s error was: %s",
|
||||
secret_file, e)
|
||||
self.exit(1)
|
||||
if not secret:
|
||||
secret_from = 'new'
|
||||
self.log.debug("Generating new %s", trait_name)
|
||||
@@ -536,7 +647,7 @@ class JupyterHub(Application):
|
||||
try:
|
||||
os.chmod(secret_file, 0o600)
|
||||
except OSError:
|
||||
self.log.warn("Failed to set permissions on %s", secret_file)
|
||||
self.log.warning("Failed to set permissions on %s", secret_file)
|
||||
# store the loaded trait value
|
||||
self.cookie_secret = secret
|
||||
|
||||
@@ -554,11 +665,15 @@ class JupyterHub(Application):
|
||||
q = self.db.query(orm.Hub)
|
||||
assert q.count() <= 1
|
||||
self._local.hub = q.first()
|
||||
if self.subdomain_host and self._local.hub:
|
||||
self._local.hub.host = self.subdomain_host
|
||||
return self._local.hub
|
||||
|
||||
@hub.setter
|
||||
def hub(self, hub):
|
||||
self._local.hub = hub
|
||||
if hub and self.subdomain_host:
|
||||
hub.host = self.subdomain_host
|
||||
|
||||
@property
|
||||
def proxy(self):
|
||||
@@ -611,6 +726,10 @@ class JupyterHub(Application):
|
||||
server.ip = self.hub_ip
|
||||
server.port = self.hub_port
|
||||
server.base_url = self.hub_prefix
|
||||
if self.subdomain_host:
|
||||
if not self.subdomain_host:
|
||||
raise ValueError("Must specify subdomain_host when using subdomains."
|
||||
" This should be the public domain[:port] of the Hub.")
|
||||
|
||||
self.db.commit()
|
||||
|
||||
@@ -620,18 +739,23 @@ class JupyterHub(Application):
|
||||
db = self.db
|
||||
|
||||
if self.admin_users and not self.authenticator.admin_users:
|
||||
self.log.warn(
|
||||
self.log.warning(
|
||||
"\nJupyterHub.admin_users is deprecated."
|
||||
"\nUse Authenticator.admin_users instead."
|
||||
)
|
||||
self.authenticator.admin_users = self.admin_users
|
||||
admin_users = self.authenticator.admin_users
|
||||
admin_users = [
|
||||
self.authenticator.normalize_username(name)
|
||||
for name in self.authenticator.admin_users
|
||||
]
|
||||
self.authenticator.admin_users = set(admin_users) # force normalization
|
||||
for username in admin_users:
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("username %r is not valid" % username)
|
||||
|
||||
if not admin_users:
|
||||
# add current user as admin if there aren't any others
|
||||
admins = db.query(orm.User).filter(orm.User.admin==True)
|
||||
if admins.first() is None:
|
||||
admin_users.add(getuser())
|
||||
self.log.warning("No admin users, admin interface will be unavailable.")
|
||||
self.log.warning("Add any administrative users to `c.Authenticator.admin_users` in config.")
|
||||
|
||||
new_users = []
|
||||
|
||||
@@ -648,7 +772,14 @@ class JupyterHub(Application):
|
||||
# the admin_users config variable will never be used after this point.
|
||||
# only the database values will be referenced.
|
||||
|
||||
whitelist = self.authenticator.whitelist
|
||||
whitelist = [
|
||||
self.authenticator.normalize_username(name)
|
||||
for name in self.authenticator.whitelist
|
||||
]
|
||||
self.authenticator.whitelist = set(whitelist) # force normalization
|
||||
for username in whitelist:
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("username %r is not valid" % username)
|
||||
|
||||
if not whitelist:
|
||||
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
|
||||
@@ -661,23 +792,51 @@ class JupyterHub(Application):
|
||||
new_users.append(user)
|
||||
db.add(user)
|
||||
|
||||
if whitelist:
|
||||
# fill the whitelist with any users loaded from the db,
|
||||
# so we are consistent in both directions.
|
||||
db.commit()
|
||||
|
||||
# Notify authenticator of all users.
|
||||
# This ensures Auth whitelist is up-to-date with the database.
|
||||
# This lets whitelist be used to set up initial list,
|
||||
# but changes to the whitelist can occur in the database,
|
||||
# and persist across sessions.
|
||||
for user in db.query(orm.User):
|
||||
whitelist.add(user.name)
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
db.commit() # can add_user touch the db?
|
||||
|
||||
# The whitelist set and the users in the db are now the same.
|
||||
# From this point on, any user changes should be done simultaneously
|
||||
# to the whitelist set and user db, unless the whitelist is empty (all users allowed).
|
||||
|
||||
def init_api_tokens(self):
|
||||
"""Load predefined API tokens (for services) into database"""
|
||||
db = self.db
|
||||
for token, username in self.api_tokens.items():
|
||||
username = self.authenticator.normalize_username(username)
|
||||
if not self.authenticator.check_whitelist(username):
|
||||
raise ValueError("Token username %r is not in whitelist" % username)
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("Token username %r is not valid" % username)
|
||||
orm_token = orm.APIToken.find(db, token)
|
||||
if orm_token is None:
|
||||
user = orm.User.find(db, username)
|
||||
user_created = False
|
||||
if user is None:
|
||||
user_created = True
|
||||
self.log.debug("Adding user %r to database", username)
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
for user in new_users:
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
self.log.info("Adding API token for %s", username)
|
||||
try:
|
||||
user.new_api_token(token)
|
||||
except Exception:
|
||||
if user_created:
|
||||
# don't allow bad tokens to create users
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
raise
|
||||
else:
|
||||
self.log.debug("Not duplicating token %s", orm_token)
|
||||
db.commit()
|
||||
|
||||
@gen.coroutine
|
||||
@@ -696,22 +855,21 @@ class JupyterHub(Application):
|
||||
@gen.coroutine
|
||||
def user_stopped(user):
|
||||
status = yield user.spawner.poll()
|
||||
self.log.warn("User %s server stopped with exit code: %s",
|
||||
self.log.warning("User %s server stopped with exit code: %s",
|
||||
user.name, status,
|
||||
)
|
||||
yield self.proxy.delete_user(user)
|
||||
yield user.stop()
|
||||
|
||||
for user in db.query(orm.User):
|
||||
for orm_user in db.query(orm.User):
|
||||
self.users[orm_user.id] = user = User(orm_user, self.tornado_settings)
|
||||
if not user.state:
|
||||
# without spawner state, server isn't valid
|
||||
user.server = None
|
||||
user_summaries.append(_user_summary(user))
|
||||
continue
|
||||
self.log.debug("Loading state for %s from db", user.name)
|
||||
user.spawner = spawner = self.spawner_class(
|
||||
user=user, hub=self.hub, config=self.config, db=self.db,
|
||||
)
|
||||
spawner = user.spawner
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
self.log.info("%s still running", user.name)
|
||||
@@ -721,7 +879,7 @@ class JupyterHub(Application):
|
||||
# user not running. This is expected if server is None,
|
||||
# but indicates the user's server died while the Hub wasn't running
|
||||
# if user.server is defined.
|
||||
log = self.log.warn if user.server else self.log.debug
|
||||
log = self.log.warning if user.server else self.log.debug
|
||||
log("%s not running.", user.name)
|
||||
user.server = None
|
||||
|
||||
@@ -779,13 +937,34 @@ class JupyterHub(Application):
|
||||
'--api-ip', self.proxy.api_server.ip,
|
||||
'--api-port', str(self.proxy.api_server.port),
|
||||
'--default-target', self.hub.server.host,
|
||||
'--error-target', url_path_join(self.hub.server.url, 'error'),
|
||||
]
|
||||
if self.subdomain_host:
|
||||
cmd.append('--host-routing')
|
||||
if self.debug_proxy:
|
||||
cmd.extend(['--log-level', 'debug'])
|
||||
if self.ssl_key:
|
||||
cmd.extend(['--ssl-key', self.ssl_key])
|
||||
if self.ssl_cert:
|
||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||
if self.statsd_host:
|
||||
cmd.extend([
|
||||
'--statsd-host', self.statsd_host,
|
||||
'--statsd-port', str(self.statsd_port),
|
||||
'--statsd-prefix', self.statsd_prefix + '.chp'
|
||||
])
|
||||
# Require SSL to be used or `--no-ssl` to confirm no SSL on
|
||||
if ' --ssl' not in ' '.join(cmd):
|
||||
if self.confirm_no_ssl:
|
||||
self.log.warning("Running JupyterHub without SSL."
|
||||
" There better be SSL termination happening somewhere else...")
|
||||
else:
|
||||
self.log.error(
|
||||
"Refusing to run JuptyterHub without SSL."
|
||||
" If you are terminating SSL in another layer,"
|
||||
" pass --no-ssl to tell JupyterHub to allow the proxy to listen on HTTP."
|
||||
)
|
||||
self.exit(1)
|
||||
self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url)
|
||||
self.log.debug("Proxy cmd: %s", cmd)
|
||||
try:
|
||||
@@ -826,15 +1005,19 @@ class JupyterHub(Application):
|
||||
)
|
||||
yield self.start_proxy()
|
||||
self.log.info("Setting up routes on new proxy")
|
||||
yield self.proxy.add_all_users()
|
||||
yield self.proxy.add_all_users(self.users)
|
||||
self.log.info("New proxy back up, and good to go")
|
||||
|
||||
def init_tornado_settings(self):
|
||||
"""Set up the tornado settings dict."""
|
||||
base_url = self.hub.server.base_url
|
||||
jinja_options = dict(
|
||||
autoescape=True,
|
||||
)
|
||||
jinja_options.update(self.jinja_environment_options)
|
||||
jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_paths),
|
||||
**self.jinja_environment_options
|
||||
**jinja_options
|
||||
)
|
||||
|
||||
login_url = self.authenticator.login_url(base_url)
|
||||
@@ -848,6 +1031,8 @@ class JupyterHub(Application):
|
||||
else:
|
||||
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||
|
||||
subdomain_host = self.subdomain_host
|
||||
domain = urlparse(subdomain_host).hostname
|
||||
settings = dict(
|
||||
log_function=log_request,
|
||||
config=self.config,
|
||||
@@ -870,10 +1055,15 @@ class JupyterHub(Application):
|
||||
template_path=self.template_paths,
|
||||
jinja2_env=jinja_env,
|
||||
version_hash=version_hash,
|
||||
subdomain_host=subdomain_host,
|
||||
domain=domain,
|
||||
statsd=self.statsd,
|
||||
)
|
||||
# allow configured settings to have priority
|
||||
settings.update(self.tornado_settings)
|
||||
self.tornado_settings = settings
|
||||
# constructing users requires access to tornado_settings
|
||||
self.tornado_settings['users'] = self.users
|
||||
|
||||
def init_tornado_application(self):
|
||||
"""Instantiate the tornado Application object"""
|
||||
@@ -895,7 +1085,7 @@ class JupyterHub(Application):
|
||||
self.load_config_file(self.config_file)
|
||||
self.init_logging()
|
||||
if 'JupyterHubApp' in self.config:
|
||||
self.log.warn("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
|
||||
self.log.warning("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
|
||||
'\n'.join('JupyterHubApp.{key} = {value!r}'.format(key=key, value=value)
|
||||
for key, value in self.config.JupyterHubApp.items()
|
||||
)
|
||||
@@ -910,9 +1100,10 @@ class JupyterHub(Application):
|
||||
self.init_hub()
|
||||
self.init_proxy()
|
||||
yield self.init_users()
|
||||
self.init_api_tokens()
|
||||
self.init_tornado_settings()
|
||||
yield self.init_spawners()
|
||||
self.init_handlers()
|
||||
self.init_tornado_settings()
|
||||
self.init_tornado_application()
|
||||
|
||||
@gen.coroutine
|
||||
@@ -923,7 +1114,7 @@ class JupyterHub(Application):
|
||||
if self.cleanup_servers:
|
||||
self.log.info("Cleaning up single-user servers...")
|
||||
# request (async) process termination
|
||||
for user in self.db.query(orm.User):
|
||||
for uid, user in self.users.items():
|
||||
if user.spawner is not None:
|
||||
futures.append(user.stop())
|
||||
else:
|
||||
@@ -989,22 +1180,30 @@ class JupyterHub(Application):
|
||||
def update_last_activity(self):
|
||||
"""Update User.last_activity timestamps from the proxy"""
|
||||
routes = yield self.proxy.get_routes()
|
||||
users_count = 0
|
||||
active_users_count = 0
|
||||
for prefix, route in routes.items():
|
||||
if 'user' not in route:
|
||||
# not a user route, ignore it
|
||||
continue
|
||||
user = orm.User.find(self.db, route['user'])
|
||||
if user is None:
|
||||
self.log.warn("Found no user for route: %s", route)
|
||||
self.log.warning("Found no user for route: %s", route)
|
||||
continue
|
||||
try:
|
||||
dt = datetime.strptime(route['last_activity'], ISO8601_ms)
|
||||
except Exception:
|
||||
dt = datetime.strptime(route['last_activity'], ISO8601_s)
|
||||
user.last_activity = max(user.last_activity, dt)
|
||||
# FIXME: Make this configurable duration. 30 minutes for now!
|
||||
if (datetime.now() - user.last_activity).total_seconds() < 30 * 60:
|
||||
active_users_count += 1
|
||||
users_count += 1
|
||||
self.statsd.gauge('users.running', users_count)
|
||||
self.statsd.gauge('users.active', active_users_count)
|
||||
|
||||
self.db.commit()
|
||||
yield self.proxy.check_routes(routes)
|
||||
yield self.proxy.check_routes(self.users, routes)
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
@@ -1039,7 +1238,7 @@ class JupyterHub(Application):
|
||||
self.exit(1)
|
||||
return
|
||||
|
||||
loop.add_callback(self.proxy.add_all_users)
|
||||
loop.add_callback(self.proxy.add_all_users, self.users)
|
||||
|
||||
if self.proxy_process:
|
||||
# only check / restart the proxy if we started it in the first place.
|
||||
|
@@ -1,41 +1,47 @@
|
||||
"""Simple PAM authenticator"""
|
||||
"""Base Authenticator class and the default PAM Authenticator"""
|
||||
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from grp import getgrnam
|
||||
import pipes
|
||||
import pwd
|
||||
from subprocess import check_call, check_output, CalledProcessError
|
||||
import re
|
||||
from shutil import which
|
||||
import sys
|
||||
from subprocess import Popen, PIPE, STDOUT
|
||||
|
||||
from tornado import gen
|
||||
import simplepam
|
||||
import pamela
|
||||
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import Bool, Set, Unicode, Any
|
||||
from traitlets import Bool, Set, Unicode, Dict, Any, default, observe
|
||||
|
||||
from .handlers.login import LoginHandler
|
||||
from .utils import url_path_join
|
||||
from .traitlets import Command
|
||||
|
||||
class Authenticator(LoggingConfigurable):
|
||||
"""A class for authentication.
|
||||
|
||||
The API is one method, `authenticate`, a tornado gen.coroutine.
|
||||
The primary API is one method, `authenticate`, a tornado coroutine
|
||||
for authenticating users.
|
||||
"""
|
||||
|
||||
db = Any()
|
||||
admin_users = Set(config=True,
|
||||
admin_users = Set(
|
||||
help="""set of usernames of admin users
|
||||
|
||||
If unspecified, only the user that launches the server will be admin.
|
||||
"""
|
||||
)
|
||||
whitelist = Set(config=True,
|
||||
).tag(config=True)
|
||||
whitelist = Set(
|
||||
help="""Username whitelist.
|
||||
|
||||
Use this to restrict which users can login.
|
||||
If empty, allow any user to attempt login.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
custom_html = Unicode('',
|
||||
help="""HTML login form for custom handlers.
|
||||
Override in form-based custom authenticators
|
||||
@@ -49,6 +55,89 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
)
|
||||
|
||||
username_pattern = Unicode(
|
||||
help="""Regular expression pattern for validating usernames.
|
||||
|
||||
If not defined: allow any username.
|
||||
"""
|
||||
).tag(config=True)
|
||||
@observe('username_pattern')
|
||||
def _username_pattern_changed(self, change):
|
||||
if not change['new']:
|
||||
self.username_regex = None
|
||||
self.username_regex = re.compile(change['new'])
|
||||
|
||||
username_regex = Any()
|
||||
|
||||
def validate_username(self, username):
|
||||
"""Validate a (normalized) username.
|
||||
|
||||
Return True if username is valid, False otherwise.
|
||||
"""
|
||||
if not self.username_regex:
|
||||
return True
|
||||
return bool(self.username_regex.match(username))
|
||||
|
||||
username_map = Dict(
|
||||
help="""Dictionary mapping authenticator usernames to JupyterHub users.
|
||||
|
||||
Can be used to map OAuth service names to local users, for instance.
|
||||
|
||||
Used in normalize_username.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
def normalize_username(self, username):
|
||||
"""Normalize a username.
|
||||
|
||||
Override in subclasses if usernames should have some normalization.
|
||||
Default: cast to lowercase, lookup in username_map.
|
||||
"""
|
||||
username = username.lower()
|
||||
username = self.username_map.get(username, username)
|
||||
return username
|
||||
|
||||
def check_whitelist(self, username):
|
||||
"""Check a username against our whitelist.
|
||||
|
||||
Return True if username is allowed, False otherwise.
|
||||
No whitelist means any username should be allowed.
|
||||
|
||||
Names are normalized *before* being checked against the whitelist.
|
||||
"""
|
||||
if not self.whitelist:
|
||||
# No whitelist means any name is allowed
|
||||
return True
|
||||
return username in self.whitelist
|
||||
|
||||
@gen.coroutine
|
||||
def get_authenticated_user(self, handler, data):
|
||||
"""This is the outer API for authenticating a user.
|
||||
|
||||
This calls `authenticate`, which should be overridden in subclasses,
|
||||
normalizes the username if any normalization should be done,
|
||||
and then validates the name in the whitelist.
|
||||
|
||||
Subclasses should not need to override this method.
|
||||
The various stages can be overridden separately:
|
||||
|
||||
- authenticate turns formdata into a username
|
||||
- normalize_username normalizes the username
|
||||
- check_whitelist checks against the user whitelist
|
||||
"""
|
||||
username = yield self.authenticate(handler, data)
|
||||
if username is None:
|
||||
return
|
||||
username = self.normalize_username(username)
|
||||
if not self.validate_username(username):
|
||||
self.log.warning("Disallowing invalid username %r.", username)
|
||||
return
|
||||
if self.check_whitelist(username):
|
||||
return username
|
||||
else:
|
||||
self.log.warning("User %r not in whitelist.", username)
|
||||
return
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
"""Authenticate a user with login form data.
|
||||
@@ -56,14 +145,29 @@ class Authenticator(LoggingConfigurable):
|
||||
This must be a tornado gen.coroutine.
|
||||
It must return the username on successful authentication,
|
||||
and return None on failed authentication.
|
||||
|
||||
Checking the whitelist is handled separately by the caller.
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
data (dict): The formdata of the login form.
|
||||
The default form has 'username' and 'password' fields.
|
||||
Return:
|
||||
str: the username of the authenticated user
|
||||
None: Authentication failed
|
||||
"""
|
||||
|
||||
def check_whitelist(self, user):
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
"""Hook called before spawning a user's server.
|
||||
|
||||
Can be used to do auth-related startup, e.g. opening PAM sessions.
|
||||
"""
|
||||
Return True if the whitelist is empty or user is in the whitelist.
|
||||
|
||||
def post_spawn_stop(self, user, spawner):
|
||||
"""Hook called after stopping a user container.
|
||||
|
||||
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
||||
"""
|
||||
# Parens aren't necessary here, but they make this easier to parse.
|
||||
return (not self.whitelist) or (user in self.whitelist)
|
||||
|
||||
def add_user(self, user):
|
||||
"""Add a new user
|
||||
@@ -71,8 +175,14 @@ class Authenticator(LoggingConfigurable):
|
||||
By default, this just adds the user to the whitelist.
|
||||
|
||||
Subclasses may do more extensive things,
|
||||
such as adding actual unix users.
|
||||
such as adding actual unix users,
|
||||
but they should call super to ensure the whitelist is updated.
|
||||
|
||||
Args:
|
||||
user (User): The User wrapper object
|
||||
"""
|
||||
if not self.validate_username(user.name):
|
||||
raise ValueError("Invalid username: %s" % user.name)
|
||||
if self.whitelist:
|
||||
self.whitelist.add(user.name)
|
||||
|
||||
@@ -80,46 +190,108 @@ class Authenticator(LoggingConfigurable):
|
||||
"""Triggered when a user is deleted.
|
||||
|
||||
Removes the user from the whitelist.
|
||||
Subclasses should call super to ensure the whitelist is updated.
|
||||
|
||||
Args:
|
||||
user (User): The User wrapper object
|
||||
"""
|
||||
self.whitelist.discard(user.name)
|
||||
|
||||
def login_url(self, base_url):
|
||||
"""Override to register a custom login handler"""
|
||||
"""Override to register a custom login handler
|
||||
|
||||
Generally used in combination with get_handlers.
|
||||
|
||||
Args:
|
||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||
|
||||
Returns:
|
||||
str: The login URL, e.g. '/hub/login'
|
||||
|
||||
"""
|
||||
return url_path_join(base_url, 'login')
|
||||
|
||||
def logout_url(self, base_url):
|
||||
"""Override to register a custom logout handler"""
|
||||
"""Override to register a custom logout handler.
|
||||
|
||||
Generally used in combination with get_handlers.
|
||||
|
||||
Args:
|
||||
base_url (str): the base URL of the Hub (e.g. /hub/)
|
||||
|
||||
Returns:
|
||||
str: The logout URL, e.g. '/hub/logout'
|
||||
"""
|
||||
return url_path_join(base_url, 'logout')
|
||||
|
||||
def get_handlers(self, app):
|
||||
"""Return any custom handlers the authenticator needs to register
|
||||
|
||||
(e.g. for OAuth)
|
||||
(e.g. for OAuth).
|
||||
|
||||
Args:
|
||||
app (JupyterHub Application):
|
||||
the application object, in case it needs to be accessed for info.
|
||||
Returns:
|
||||
list: list of ``('/url', Handler)`` tuples passed to tornado.
|
||||
The Hub prefix is added to any URLs.
|
||||
|
||||
"""
|
||||
return [
|
||||
('/login', LoginHandler),
|
||||
]
|
||||
|
||||
class LocalAuthenticator(Authenticator):
|
||||
"""Base class for Authenticators that work with local *ix users
|
||||
"""Base class for Authenticators that work with local Linux/UNIX users
|
||||
|
||||
Checks for local users, and can attempt to create them if they exist.
|
||||
"""
|
||||
|
||||
create_system_users = Bool(False, config=True,
|
||||
create_system_users = Bool(False,
|
||||
help="""If a user is added that doesn't exist on the system,
|
||||
should I try to create the system user?
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
add_user_cmd = Command(
|
||||
help="""The command to use for creating users as a list of strings.
|
||||
|
||||
For each element in the list, the string USERNAME will be replaced with
|
||||
the user's username. The username will also be appended as the final argument.
|
||||
|
||||
For Linux, the default value is:
|
||||
|
||||
['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||
|
||||
To specify a custom home directory, set this to:
|
||||
|
||||
['adduser', '-q', '--gecos', '""', '--home', '/customhome/USERNAME', '--disabled-password']
|
||||
|
||||
This will run the command:
|
||||
|
||||
adduser -q --gecos "" --home /customhome/river --disabled-password river
|
||||
|
||||
when the user 'river' is created.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@default('add_user_cmd')
|
||||
def _add_user_cmd_default(self):
|
||||
if sys.platform == 'darwin':
|
||||
raise ValueError("I don't know how to create users on OS X")
|
||||
elif which('pw'):
|
||||
# Probably BSD
|
||||
return ['pw', 'useradd', '-m']
|
||||
else:
|
||||
# This appears to be the Linux non-interactive adduser command:
|
||||
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
|
||||
|
||||
group_whitelist = Set(
|
||||
config=True,
|
||||
help="Automatically whitelist anyone in this group.",
|
||||
)
|
||||
|
||||
def _group_whitelist_changed(self, name, old, new):
|
||||
).tag(config=True)
|
||||
@observe('group_whitelist')
|
||||
def _group_whitelist_changed(self, change):
|
||||
if self.whitelist:
|
||||
self.log.warn(
|
||||
self.log.warning(
|
||||
"Ignoring username whitelist because group whitelist supplied!"
|
||||
)
|
||||
|
||||
@@ -146,10 +318,7 @@ class LocalAuthenticator(Authenticator):
|
||||
def add_user(self, user):
|
||||
"""Add a new user
|
||||
|
||||
By default, this just adds the user to the whitelist.
|
||||
|
||||
Subclasses may do more extensive things,
|
||||
such as adding actual unix users.
|
||||
If self.create_system_users, the user will attempt to be created.
|
||||
"""
|
||||
user_exists = yield gen.maybe_future(self.system_user_exists(user))
|
||||
if not user_exists:
|
||||
@@ -170,34 +339,38 @@ class LocalAuthenticator(Authenticator):
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def add_system_user(user):
|
||||
"""Create a new *ix user on the system. Works on FreeBSD and Linux, at least."""
|
||||
def add_system_user(self, user):
|
||||
"""Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least."""
|
||||
name = user.name
|
||||
for useradd in (
|
||||
['pw', 'useradd', '-m'],
|
||||
['useradd', '-m'],
|
||||
):
|
||||
try:
|
||||
check_output(['which', useradd[0]])
|
||||
except CalledProcessError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("I don't know how to add users on this system.")
|
||||
|
||||
check_call(useradd + [name])
|
||||
cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name]
|
||||
self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
|
||||
p = Popen(cmd, stdout=PIPE, stderr=STDOUT)
|
||||
p.wait()
|
||||
if p.returncode:
|
||||
err = p.stdout.read().decode('utf8', 'replace')
|
||||
raise RuntimeError("Failed to create system user %s: %s" % (name, err))
|
||||
|
||||
|
||||
class PAMAuthenticator(LocalAuthenticator):
|
||||
"""Authenticate local *ix users with PAM"""
|
||||
encoding = Unicode('utf8', config=True,
|
||||
"""Authenticate local Linux/UNIX users with PAM"""
|
||||
encoding = Unicode('utf8',
|
||||
help="""The encoding to use for PAM"""
|
||||
)
|
||||
service = Unicode('login', config=True,
|
||||
).tag(config=True)
|
||||
service = Unicode('login',
|
||||
help="""The PAM service to use for authentication."""
|
||||
)
|
||||
).tag(config=True)
|
||||
open_sessions = Bool(True,
|
||||
help="""Whether to open PAM sessions when spawners are started.
|
||||
|
||||
This may trigger things like mounting shared filsystems,
|
||||
loading credentials, etc. depending on system configuration,
|
||||
but it does not always work.
|
||||
|
||||
It can be disabled with::
|
||||
|
||||
c.PAMAuthenticator.open_sessions = False
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
@@ -206,12 +379,35 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
Return None otherwise.
|
||||
"""
|
||||
username = data['username']
|
||||
if not self.check_whitelist(username):
|
||||
return
|
||||
# simplepam wants bytes, not unicode
|
||||
# see simplepam#3
|
||||
busername = username.encode(self.encoding)
|
||||
bpassword = data['password'].encode(self.encoding)
|
||||
if simplepam.authenticate(busername, bpassword, service=self.service):
|
||||
try:
|
||||
pamela.authenticate(username, data['password'], service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
if handler is not None:
|
||||
self.log.warning("PAM Authentication failed (%s@%s): %s", username, handler.request.remote_ip, e)
|
||||
else:
|
||||
self.log.warning("PAM Authentication failed: %s", e)
|
||||
else:
|
||||
return username
|
||||
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
"""Open PAM session for user"""
|
||||
if not self.open_sessions:
|
||||
return
|
||||
try:
|
||||
pamela.open_session(user.name, service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
self.log.warning("Failed to open PAM session for %s: %s", user.name, e)
|
||||
self.log.warning("Disabling PAM sessions from now on.")
|
||||
self.open_sessions = False
|
||||
|
||||
def post_spawn_stop(self, user, spawner):
|
||||
"""Close PAM session for user"""
|
||||
if not self.open_sessions:
|
||||
return
|
||||
try:
|
||||
pamela.close_session(user.name, service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
self.log.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
|
@@ -4,7 +4,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from http.client import responses
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
@@ -16,6 +16,7 @@ from tornado.web import RequestHandler
|
||||
from tornado import gen, web
|
||||
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -50,10 +51,22 @@ class BaseHandler(RequestHandler):
|
||||
def version_hash(self):
|
||||
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
|
||||
def db(self):
|
||||
return self.settings['db']
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
return self.settings.setdefault('users', {})
|
||||
|
||||
@property
|
||||
def hub(self):
|
||||
return self.settings['hub']
|
||||
@@ -62,6 +75,10 @@ class BaseHandler(RequestHandler):
|
||||
def proxy(self):
|
||||
return self.settings['proxy']
|
||||
|
||||
@property
|
||||
def statsd(self):
|
||||
return self.settings['statsd']
|
||||
|
||||
@property
|
||||
def authenticator(self):
|
||||
return self.settings.get('authenticator', None)
|
||||
@@ -141,17 +158,24 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
if cookie_id is None:
|
||||
if self.get_cookie(cookie_name):
|
||||
self.log.warn("Invalid or expired cookie token")
|
||||
self.log.warning("Invalid or expired cookie token")
|
||||
clear()
|
||||
return
|
||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||
user = 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)
|
||||
if user is None:
|
||||
self.log.warn("Invalid cookie token")
|
||||
self.log.warning("Invalid cookie token")
|
||||
# have cookie, but it's not valid. Clear it and start over.
|
||||
clear()
|
||||
return user
|
||||
|
||||
def _user_from_orm(self, orm_user):
|
||||
"""return User wrapper from orm.User object"""
|
||||
if orm_user is None:
|
||||
return
|
||||
return self.users[orm_user]
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""get_current_user from a cookie token"""
|
||||
return self._user_for_cookie(self.hub.server.cookie_name)
|
||||
@@ -168,55 +192,64 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
return None if no such user
|
||||
"""
|
||||
return orm.User.find(self.db, name)
|
||||
orm_user = orm.User.find(db=self.db, name=name)
|
||||
return self._user_from_orm(orm_user)
|
||||
|
||||
def user_from_username(self, username):
|
||||
"""Get ORM User for username"""
|
||||
"""Get User for username, creating if it doesn't exist"""
|
||||
user = self.find_user(username)
|
||||
if user is None:
|
||||
user = orm.User(name=username)
|
||||
self.db.add(user)
|
||||
# not found, create and register user
|
||||
u = orm.User(name=username)
|
||||
self.db.add(u)
|
||||
self.db.commit()
|
||||
user = self._user_from_orm(u)
|
||||
self.authenticator.add_user(user)
|
||||
return user
|
||||
|
||||
def clear_login_cookie(self):
|
||||
def clear_login_cookie(self, name=None):
|
||||
if name is None:
|
||||
user = self.get_current_user()
|
||||
else:
|
||||
user = self.find_user(name)
|
||||
kwargs = {}
|
||||
if self.subdomain_host:
|
||||
kwargs['domain'] = self.domain
|
||||
if user and user.server:
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
|
||||
|
||||
def _set_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):
|
||||
"""set the login cookie for the single-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 = {}
|
||||
self.set_secure_cookie(
|
||||
user.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=user.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
self._set_user_cookie(user, user.server)
|
||||
|
||||
def set_hub_cookie(self, user):
|
||||
"""set the login cookie for the Hub"""
|
||||
# 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 = {}
|
||||
self.set_secure_cookie(
|
||||
self.hub.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=self.hub.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
self._set_user_cookie(user, self.hub.server)
|
||||
|
||||
def set_login_cookie(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
if self.subdomain_host and not self.request.host.startswith(self.domain):
|
||||
self.log.warning(
|
||||
"Possibly setting cookie on wrong domain: %s != %s",
|
||||
self.request.host, self.domain)
|
||||
# create and set a new cookie token for the single-user server
|
||||
if user.server:
|
||||
self.set_server_cookie(user)
|
||||
@@ -229,7 +262,7 @@ class BaseHandler(RequestHandler):
|
||||
def authenticate(self, data):
|
||||
auth = self.authenticator
|
||||
if auth is not None:
|
||||
result = yield auth.authenticate(self, data)
|
||||
result = yield auth.get_authenticated_user(self, data)
|
||||
return result
|
||||
else:
|
||||
self.log.error("No authentication function, login is impossible!")
|
||||
@@ -252,17 +285,13 @@ class BaseHandler(RequestHandler):
|
||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, user):
|
||||
def spawn_single_user(self, user, options=None):
|
||||
if user.spawn_pending:
|
||||
raise RuntimeError("Spawn already pending for: %s" % user.name)
|
||||
tic = IOLoop.current().time()
|
||||
|
||||
f = user.spawn(
|
||||
spawner_class=self.spawner_class,
|
||||
base_url=self.base_url,
|
||||
hub=self.hub,
|
||||
config=self.config,
|
||||
)
|
||||
f = user.spawn(options)
|
||||
|
||||
@gen.coroutine
|
||||
def finish_user_spawn(f=None):
|
||||
"""Finish the user spawn by registering listeners and notifying the proxy.
|
||||
@@ -275,6 +304,7 @@ class BaseHandler(RequestHandler):
|
||||
return
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
|
||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||
yield self.proxy.add_user(user)
|
||||
user.spawner.add_poll_callback(self.user_stopped, user)
|
||||
|
||||
@@ -282,12 +312,25 @@ class BaseHandler(RequestHandler):
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||
except gen.TimeoutError:
|
||||
if user.spawn_pending:
|
||||
# hit timeout, but spawn is still pending
|
||||
self.log.warn("User %s server is slow to start", user.name)
|
||||
# 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:
|
||||
raise
|
||||
# start has finished, but the server hasn't come up
|
||||
# check if the server died while we were waiting
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
# hit timeout, but server's running. Hope that it'll show up soon enough,
|
||||
# though it's possible that it started at the wrong URL
|
||||
self.log.warning("User %s server is slow to become responsive", user.name)
|
||||
# schedule finish for when the user finishes spawning
|
||||
IOLoop.current().add_future(f, finish_user_spawn)
|
||||
else:
|
||||
toc = IOLoop.current().time()
|
||||
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||
else:
|
||||
yield finish_user_spawn()
|
||||
|
||||
@@ -297,7 +340,7 @@ class BaseHandler(RequestHandler):
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
status = 'unknown'
|
||||
self.log.warn("User %s server stopped, with exit code: %s",
|
||||
self.log.warning("User %s server stopped, with exit code: %s",
|
||||
user.name, status,
|
||||
)
|
||||
yield self.proxy.delete_user(user)
|
||||
@@ -328,7 +371,7 @@ class BaseHandler(RequestHandler):
|
||||
except gen.TimeoutError:
|
||||
if user.stop_pending:
|
||||
# hit timeout, but stop is still pending
|
||||
self.log.warn("User %s server is slow to stop", user.name)
|
||||
self.log.warning("User %s server is slow to stop", user.name)
|
||||
# schedule finish for when the server finishes stopping
|
||||
IOLoop.current().add_future(f, finish_stop)
|
||||
else:
|
||||
@@ -367,6 +410,7 @@ class BaseHandler(RequestHandler):
|
||||
"""render custom error pages"""
|
||||
exc_info = kwargs.get('exc_info')
|
||||
message = ''
|
||||
exception = None
|
||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||
if exc_info:
|
||||
exception = exc_info[1]
|
||||
@@ -419,18 +463,23 @@ class PrefixRedirectHandler(BaseHandler):
|
||||
|
||||
|
||||
class UserSpawnHandler(BaseHandler):
|
||||
"""Requests to /user/name handled by the Hub
|
||||
should result in spawning the single-user server and
|
||||
being redirected to the original.
|
||||
"""Redirect requests to /user/name/* handled by the Hub.
|
||||
|
||||
If logged in, spawn a single-user server and redirect request.
|
||||
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
||||
redirect her to /user/alice/notebooks/mynotebook.ipynb, which should
|
||||
in turn call this function.
|
||||
"""
|
||||
|
||||
@gen.coroutine
|
||||
def get(self, name):
|
||||
def get(self, name, user_path):
|
||||
current_user = self.get_current_user()
|
||||
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.spawn_pending:
|
||||
# spawn has started, but not finished
|
||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||
html = self.render_template("spawn_pending.html", user=current_user)
|
||||
self.finish(html)
|
||||
return
|
||||
@@ -438,32 +487,47 @@ class UserSpawnHandler(BaseHandler):
|
||||
# spawn has supposedly finished, check on the status
|
||||
status = yield current_user.spawner.poll()
|
||||
if status is not None:
|
||||
yield self.spawn_single_user(current_user)
|
||||
if current_user.spawner.options_form:
|
||||
self.redirect(url_path_join(self.hub.server.base_url, 'spawn'))
|
||||
return
|
||||
else:
|
||||
yield self.spawn_single_user(current_user)
|
||||
# set login cookie anew
|
||||
self.set_login_cookie(current_user)
|
||||
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||
target = url_path_join(self.base_url, without_prefix)
|
||||
if self.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)
|
||||
else:
|
||||
# not logged in to the right user,
|
||||
# clear any cookies and reload (will redirect to login)
|
||||
# not logged in, clear any cookies and reload
|
||||
self.statsd.incr('redirects.user_to_login', 1)
|
||||
self.clear_login_cookie()
|
||||
self.redirect(url_concat(
|
||||
self.settings['login_url'],
|
||||
{'next': self.request.uri,
|
||||
}))
|
||||
{'next': self.request.uri},
|
||||
))
|
||||
|
||||
|
||||
class CSPReportHandler(BaseHandler):
|
||||
'''Accepts a content security policy violation report'''
|
||||
@web.authenticated
|
||||
def post(self):
|
||||
'''Log a content security policy violation report'''
|
||||
self.log.warn("Content security violation: %s",
|
||||
self.request.body.decode('utf8', 'replace'))
|
||||
self.log.warning(
|
||||
"Content security violation: %s",
|
||||
self.request.body.decode('utf8', 'replace')
|
||||
)
|
||||
# Report it to statsd as well
|
||||
self.statsd.incr('csp_report')
|
||||
|
||||
default_handlers = [
|
||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||
(r'/security/csp-report', CSPReportHandler),
|
||||
]
|
||||
|
@@ -16,6 +16,10 @@ class LogoutHandler(BaseHandler):
|
||||
if user:
|
||||
self.log.info("User logged out: %s", user.name)
|
||||
self.clear_login_cookie()
|
||||
for name in user.other_user_cookies:
|
||||
self.clear_login_cookie(name)
|
||||
user.other_user_cookies = set([])
|
||||
self.statsd.incr('logout')
|
||||
self.redirect(self.hub.server.base_url, permanent=False)
|
||||
|
||||
|
||||
@@ -27,10 +31,12 @@ class LoginHandler(BaseHandler):
|
||||
next=url_escape(self.get_argument('next', default='')),
|
||||
username=username,
|
||||
login_error=login_error,
|
||||
custom_login_form=self.authenticator.custom_html,
|
||||
custom_html=self.authenticator.custom_html,
|
||||
login_url=self.settings['login_url'],
|
||||
)
|
||||
|
||||
def get(self):
|
||||
self.statsd.incr('login.request')
|
||||
next_url = self.get_argument('next', '')
|
||||
if not next_url.startswith('/'):
|
||||
# disallow non-absolute next URLs (e.g. full URLs)
|
||||
@@ -39,7 +45,7 @@ class LoginHandler(BaseHandler):
|
||||
if user:
|
||||
if not next_url:
|
||||
if user.running:
|
||||
next_url = user.server.base_url
|
||||
next_url = user.url
|
||||
else:
|
||||
next_url = self.hub.server.base_url
|
||||
# set new login cookie
|
||||
@@ -57,15 +63,19 @@ class LoginHandler(BaseHandler):
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg)
|
||||
|
||||
username = data['username']
|
||||
authorized = yield self.authenticate(data)
|
||||
if authorized:
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
username = yield self.authenticate(data)
|
||||
auth_timer.stop(send=False)
|
||||
|
||||
if username:
|
||||
self.statsd.incr('login.success')
|
||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||
user = self.user_from_username(username)
|
||||
already_running = False
|
||||
if user.spawner:
|
||||
status = yield user.spawner.poll()
|
||||
already_running = (status == None)
|
||||
if not already_running:
|
||||
if not already_running and not user.spawner.options_form:
|
||||
yield self.spawn_single_user(user)
|
||||
self.set_login_cookie(user)
|
||||
next_url = self.get_argument('next', default='')
|
||||
@@ -75,7 +85,9 @@ class LoginHandler(BaseHandler):
|
||||
self.redirect(next_url)
|
||||
self.log.info("User logged in: %s", username)
|
||||
else:
|
||||
self.log.debug("Failed login for %s", username)
|
||||
self.statsd.incr('login.failure')
|
||||
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
||||
self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
|
||||
html = self._render(
|
||||
login_error='Invalid username or password',
|
||||
username=username,
|
||||
@@ -83,7 +95,10 @@ class LoginHandler(BaseHandler):
|
||||
self.finish(html)
|
||||
|
||||
|
||||
# Only logout is a default handler.
|
||||
# /login renders the login page or the "Login with..." link,
|
||||
# so it should always be registered.
|
||||
# /logout clears cookies.
|
||||
default_handlers = [
|
||||
(r"/login", LoginHandler),
|
||||
(r"/logout", LogoutHandler),
|
||||
]
|
||||
|
@@ -3,7 +3,10 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from tornado import web
|
||||
from http.client import responses
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
from tornado import web, gen
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only, url_path_join
|
||||
@@ -24,28 +27,90 @@ class RootHandler(BaseHandler):
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
if user.running:
|
||||
url = user.server.base_url
|
||||
url = user.url
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.set_login_cookie(user) # set cookie
|
||||
else:
|
||||
url = url_path_join(self.hub.server.base_url, 'home')
|
||||
self.redirect(url, permanent=False)
|
||||
self.log.debug("User is not running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
html = self.render_template('login.html',
|
||||
login_url=self.settings['login_url'],
|
||||
custom_html=self.authenticator.custom_html,
|
||||
)
|
||||
self.finish(html)
|
||||
url = url_path_join(self.hub.server.base_url, 'login')
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
class HomeHandler(BaseHandler):
|
||||
"""Render the user's home page."""
|
||||
|
||||
@web.authenticated
|
||||
@gen.coroutine
|
||||
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',
|
||||
user=self.get_current_user(),
|
||||
user=user,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class SpawnHandler(BaseHandler):
|
||||
"""Handle spawning of single-user servers via form.
|
||||
|
||||
GET renders the form, POST handles form submission.
|
||||
|
||||
Only enabled when Spawner.options_form is defined.
|
||||
"""
|
||||
def _render_form(self, message=''):
|
||||
user = self.get_current_user()
|
||||
return self.render_template('spawn.html',
|
||||
user=user,
|
||||
spawner_options_form=user.spawner.options_form,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
"""GET renders form for spawning with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
url = user.url
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
if user.spawner.options_form:
|
||||
self.finish(self._render_form())
|
||||
else:
|
||||
# not running, no form. Trigger spawn.
|
||||
self.redirect(user.url)
|
||||
|
||||
@web.authenticated
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
"""POST spawns with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
url = user.url
|
||||
self.log.warning("User is already running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
form_options = {}
|
||||
for key, byte_list in self.request.body_arguments.items():
|
||||
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
||||
for key, byte_list in self.request.files.items():
|
||||
form_options["%s_file"%key] = byte_list
|
||||
try:
|
||||
options = user.spawner.options_from_form(form_options)
|
||||
yield self.spawn_single_user(user, options=options)
|
||||
except Exception as e:
|
||||
self.log.error("Failed to spawn single-user server with form", exc_info=True)
|
||||
self.finish(self._render_form(str(e)))
|
||||
return
|
||||
self.set_login_cookie(user)
|
||||
url = user.url
|
||||
self.redirect(url)
|
||||
|
||||
class AdminHandler(BaseHandler):
|
||||
"""Render the admin page."""
|
||||
|
||||
@@ -66,10 +131,10 @@ class AdminHandler(BaseHandler):
|
||||
orders = self.get_arguments('order')
|
||||
|
||||
for bad in set(sorts).difference(available):
|
||||
self.log.warn("ignoring invalid sort: %r", bad)
|
||||
self.log.warning("ignoring invalid sort: %r", bad)
|
||||
sorts.remove(bad)
|
||||
for bad in set(orders).difference({'asc', 'desc'}):
|
||||
self.log.warn("ignoring invalid order: %r", bad)
|
||||
self.log.warning("ignoring invalid order: %r", bad)
|
||||
orders.remove(bad)
|
||||
|
||||
# add default sort as secondary
|
||||
@@ -89,7 +154,8 @@ class AdminHandler(BaseHandler):
|
||||
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
||||
|
||||
users = self.db.query(orm.User).order_by(*ordered)
|
||||
running = users.filter(orm.User.server != None)
|
||||
users = [ self._user_from_orm(u) for u in users ]
|
||||
running = [ u for u in users if u.running ]
|
||||
|
||||
html = self.render_template('admin.html',
|
||||
user=self.get_current_user(),
|
||||
@@ -101,8 +167,43 @@ class AdminHandler(BaseHandler):
|
||||
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 = [
|
||||
(r'/', RootHandler),
|
||||
(r'/home', HomeHandler),
|
||||
(r'/admin', AdminHandler),
|
||||
(r'/spawn', SpawnHandler),
|
||||
(r'/error/(\d+)', ProxyErrorHandler),
|
||||
]
|
||||
|
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
from tornado.web import StaticFileHandler
|
||||
|
||||
class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||
@@ -15,3 +16,13 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
|
||||
if "v" not in self.request.arguments:
|
||||
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)
|
||||
|
||||
|
@@ -3,17 +3,14 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import errno
|
||||
from datetime import datetime
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import quote
|
||||
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError
|
||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
from sqlalchemy import (
|
||||
inspect,
|
||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||
@@ -27,7 +24,7 @@ from sqlalchemy import create_engine
|
||||
|
||||
from .utils import (
|
||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||
new_token, hash_token, compare_token,
|
||||
new_token, hash_token, compare_token, can_connect,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +37,7 @@ class JSONDict(TypeDecorator):
|
||||
|
||||
"""
|
||||
|
||||
impl = VARCHAR
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
@@ -65,11 +62,11 @@ class Server(Base):
|
||||
"""
|
||||
__tablename__ = 'servers'
|
||||
id = Column(Integer, primary_key=True)
|
||||
proto = Column(Unicode, default='http')
|
||||
ip = Column(Unicode, default='')
|
||||
proto = Column(Unicode(15), default='http')
|
||||
ip = Column(Unicode(255), default='') # could also be a DNS name
|
||||
port = Column(Integer, default=random_port)
|
||||
base_url = Column(Unicode, default='/')
|
||||
cookie_name = Column(Unicode, default='cookie')
|
||||
base_url = Column(Unicode(255), default='/')
|
||||
cookie_name = Column(Unicode(255), default='cookie')
|
||||
|
||||
def __repr__(self):
|
||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||
@@ -79,7 +76,7 @@ class Server(Base):
|
||||
ip = self.ip
|
||||
if ip in {'', '0.0.0.0'}:
|
||||
# when listening on all interfaces, connect to localhost
|
||||
ip = 'localhost'
|
||||
ip = '127.0.0.1'
|
||||
return "{proto}://{ip}:{port}".format(
|
||||
proto=self.proto,
|
||||
ip=ip,
|
||||
@@ -101,7 +98,7 @@ class Server(Base):
|
||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||
"""
|
||||
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
|
||||
|
||||
@gen.coroutine
|
||||
@@ -110,19 +107,11 @@ class Server(Base):
|
||||
if http:
|
||||
yield wait_for_http_server(self.url, timeout=timeout)
|
||||
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):
|
||||
"""Is the server accepting connections?"""
|
||||
try:
|
||||
socket.create_connection((self.ip or 'localhost', self.port))
|
||||
except socket.error as e:
|
||||
if e.errno == errno.ECONNREFUSED:
|
||||
return False
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
return can_connect(self.ip or '127.0.0.1', self.port)
|
||||
|
||||
|
||||
class Proxy(Base):
|
||||
@@ -167,10 +156,14 @@ class Proxy(Base):
|
||||
def add_user(self, user, client=None):
|
||||
"""Add a user's server to the proxy table."""
|
||||
self.log.info("Adding user %s to proxy %s => %s",
|
||||
user.name, user.server.base_url, user.server.host,
|
||||
user.name, user.proxy_path, user.server.host,
|
||||
)
|
||||
|
||||
yield self.api_request(user.server.base_url,
|
||||
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',
|
||||
body=dict(
|
||||
target=user.server.host,
|
||||
@@ -183,26 +176,11 @@ class Proxy(Base):
|
||||
def delete_user(self, user, client=None):
|
||||
"""Remove a user's server to the proxy table."""
|
||||
self.log.info("Removing user %s from proxy", user.name)
|
||||
yield self.api_request(user.server.base_url,
|
||||
yield self.api_request(user.proxy_path,
|
||||
method='DELETE',
|
||||
client=client,
|
||||
)
|
||||
|
||||
@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
|
||||
def get_routes(self, client=None):
|
||||
"""Fetch the proxy's routes"""
|
||||
@@ -210,17 +188,42 @@ class Proxy(Base):
|
||||
return json.loads(resp.body.decode('utf8', 'replace'))
|
||||
|
||||
@gen.coroutine
|
||||
def check_routes(self, routes=None):
|
||||
"""Check that all users are properly"""
|
||||
def add_all_users(self, user_dict):
|
||||
"""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:
|
||||
routes = yield self.get_routes()
|
||||
|
||||
have_routes = { r['user'] for r in routes.values() if 'user' in r }
|
||||
futures = []
|
||||
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:
|
||||
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))
|
||||
for f in futures:
|
||||
yield f
|
||||
@@ -239,6 +242,7 @@ class Hub(Base):
|
||||
id = Column(Integer, primary_key=True)
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
host = ''
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
@@ -271,8 +275,8 @@ class User(Base):
|
||||
used for restoring state of a Spawner.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(Unicode)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(1023))
|
||||
# should we allow multiple servers per user?
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
@@ -280,11 +284,10 @@ class User(Base):
|
||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user")
|
||||
cookie_id = Column(Unicode, default=new_token)
|
||||
cookie_id = Column(Unicode(1023), default=new_token)
|
||||
state = Column(JSONDict)
|
||||
spawner = None
|
||||
spawn_pending = False
|
||||
stop_pending = False
|
||||
|
||||
other_user_cookies = set([])
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
@@ -300,25 +303,21 @@ class User(Base):
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
@property
|
||||
def escaped_name(self):
|
||||
"""My name, escaped for use in URLs, cookies, etc."""
|
||||
return quote(self.name, safe='@')
|
||||
def new_api_token(self, token=None):
|
||||
"""Create a new API token
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
"""property for whether a user has a running server"""
|
||||
if self.spawner is None:
|
||||
return False
|
||||
if self.server is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def new_api_token(self):
|
||||
"""Create a new API token"""
|
||||
If `token` is given, load that token.
|
||||
"""
|
||||
assert self.id is not None
|
||||
db = inspect(self).session
|
||||
if token is None:
|
||||
token = new_token()
|
||||
else:
|
||||
if len(token) < 8:
|
||||
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
|
||||
found = APIToken.find(db, token)
|
||||
if found:
|
||||
raise ValueError("Collision on token: %s..." % token[:4])
|
||||
orm_token = APIToken(user_id=self.id)
|
||||
orm_token.token = token
|
||||
db.add(orm_token)
|
||||
@@ -333,115 +332,6 @@ class User(Base):
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
|
||||
@gen.coroutine
|
||||
def spawn(self, spawner_class, base_url='/', hub=None, config=None):
|
||||
"""Start the user's spawner"""
|
||||
db = inspect(self).session
|
||||
if hub is None:
|
||||
hub = db.query(Hub).first()
|
||||
|
||||
self.server = Server(
|
||||
cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')),
|
||||
base_url=url_path_join(base_url, 'user', self.escaped_name),
|
||||
)
|
||||
db.add(self.server)
|
||||
db.commit()
|
||||
|
||||
api_token = self.new_api_token()
|
||||
db.commit()
|
||||
|
||||
spawner = self.spawner = spawner_class(
|
||||
config=config,
|
||||
user=self,
|
||||
hub=hub,
|
||||
db=db,
|
||||
)
|
||||
# we are starting a new server, make sure it doesn't restore state
|
||||
spawner.clear_state()
|
||||
spawner.api_token = api_token
|
||||
|
||||
self.spawn_pending = True
|
||||
# wait for spawner.start to return
|
||||
try:
|
||||
f = spawner.start()
|
||||
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
||||
except Exception as e:
|
||||
if isinstance(e, gen.TimeoutError):
|
||||
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e,
|
||||
))
|
||||
e.reason = 'error'
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name,
|
||||
), exc_info=True)
|
||||
# raise original exception
|
||||
raise e
|
||||
spawner.start_polling()
|
||||
|
||||
# store state
|
||||
self.state = spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
db.commit()
|
||||
try:
|
||||
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
self.log.warn(
|
||||
"{user}'s server never showed up at {url} "
|
||||
"after {http_timeout} seconds. Giving up".format(
|
||||
user=self.name,
|
||||
url=self.server.url,
|
||||
http_timeout=spawner.http_timeout,
|
||||
)
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
e.reason = 'error'
|
||||
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||
user=self.name, url=self.server.url, error=e,
|
||||
))
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name,
|
||||
), exc_info=True)
|
||||
# raise original TimeoutError
|
||||
raise e
|
||||
self.spawn_pending = False
|
||||
return self
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
"""Stop the user's spawner
|
||||
|
||||
and cleanup after it.
|
||||
"""
|
||||
self.spawn_pending = False
|
||||
if self.spawner is None:
|
||||
return
|
||||
self.spawner.stop_polling()
|
||||
self.stop_pending = True
|
||||
try:
|
||||
status = yield self.spawner.poll()
|
||||
if status is None:
|
||||
yield self.spawner.stop()
|
||||
self.spawner.clear_state()
|
||||
self.state = self.spawner.get_state()
|
||||
self.server = None
|
||||
inspect(self).session.commit()
|
||||
finally:
|
||||
self.stop_pending = False
|
||||
|
||||
|
||||
class APIToken(Base):
|
||||
"""An API token"""
|
||||
__tablename__ = 'api_tokens'
|
||||
@@ -451,8 +341,8 @@ class APIToken(Base):
|
||||
return Column(Integer, ForeignKey('users.id'))
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
hashed = Column(Unicode)
|
||||
prefix = Column(Unicode)
|
||||
hashed = Column(Unicode(1023))
|
||||
prefix = Column(Unicode(1023))
|
||||
prefix_length = 4
|
||||
algorithm = "sha512"
|
||||
rounds = 16384
|
||||
@@ -498,6 +388,8 @@ def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||
"""Create a new session at url"""
|
||||
if url.startswith('sqlite'):
|
||||
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
||||
elif url.startswith('mysql'):
|
||||
kwargs.setdefault('pool_recycle', 60)
|
||||
|
||||
if url.endswith(':memory:'):
|
||||
# If we're using an in-memory database, ensure that only one connection
|
||||
|
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extend regular notebook server to be aware of multiuser things."""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
# PY2 Compat
|
||||
from urllib import quote
|
||||
|
||||
import requests
|
||||
from jinja2 import ChoiceLoader, FunctionLoader
|
||||
|
||||
from tornado import ioloop
|
||||
from tornado.web import HTTPError
|
||||
|
||||
|
||||
from IPython.utils.traitlets import (
|
||||
Integer,
|
||||
Unicode,
|
||||
CUnicode,
|
||||
)
|
||||
|
||||
from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases
|
||||
from IPython.html.auth.login import LoginHandler
|
||||
from IPython.html.auth.logout import LogoutHandler
|
||||
|
||||
from IPython.html.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__)
|
||||
|
||||
# Define two methods to attach to AuthenticatedHandler,
|
||||
# which authenticate via the central auth server.
|
||||
|
||||
class JupyterHubLoginHandler(LoginHandler):
|
||||
@staticmethod
|
||||
def login_available(settings):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_token(self, cookie_name, encrypted_cookie):
|
||||
"""method for token verification"""
|
||||
cookie_cache = self.settings['cookie_cache']
|
||||
if encrypted_cookie in cookie_cache:
|
||||
# we've seen this token before, don't ask upstream again
|
||||
return cookie_cache[encrypted_cookie]
|
||||
|
||||
hub_api_url = self.settings['hub_api_url']
|
||||
hub_api_key = self.settings['hub_api_key']
|
||||
r = requests.get(url_path_join(
|
||||
hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''),
|
||||
),
|
||||
headers = {'Authorization' : 'token %s' % hub_api_key},
|
||||
)
|
||||
if r.status_code == 404:
|
||||
data = None
|
||||
elif r.status_code == 403:
|
||||
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
|
||||
elif r.status_code >= 500:
|
||||
self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(502, "Failed to check authorization (upstream problem)")
|
||||
elif r.status_code >= 400:
|
||||
self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Failed to check authorization")
|
||||
else:
|
||||
data = r.json()
|
||||
cookie_cache[encrypted_cookie] = data
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def get_user(self):
|
||||
"""alternative get_current_user to query the central server"""
|
||||
# only allow this to be called once per handler
|
||||
# avoids issues if an error is raised,
|
||||
# since this may be called again when trying to render the error page
|
||||
if hasattr(self, '_cached_user'):
|
||||
return self._cached_user
|
||||
|
||||
self._cached_user = None
|
||||
my_user = self.settings['user']
|
||||
encrypted_cookie = self.get_cookie(self.cookie_name)
|
||||
if encrypted_cookie:
|
||||
auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie)
|
||||
if not auth_data:
|
||||
# treat invalid token the same as no token
|
||||
return None
|
||||
user = auth_data['name']
|
||||
if user == my_user:
|
||||
self._cached_user = user
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
self.log.debug("No token cookie")
|
||||
return None
|
||||
|
||||
|
||||
class JupyterHubLogoutHandler(LogoutHandler):
|
||||
def get(self):
|
||||
self.redirect(url_path_join(self.settings['hub_prefix'], 'logout'))
|
||||
|
||||
|
||||
# register new hub related command-line aliases
|
||||
aliases = dict(notebook_aliases)
|
||||
aliases.update({
|
||||
'user' : 'SingleUserNotebookApp.user',
|
||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||
'base-url': 'SingleUserNotebookApp.base_url',
|
||||
})
|
||||
|
||||
page_template = """
|
||||
{% extends "templates/page.html" %}
|
||||
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'
|
||||
>
|
||||
Control Panel</a>
|
||||
{% endblock %}
|
||||
"""
|
||||
|
||||
class SingleUserNotebookApp(NotebookApp):
|
||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||
user = CUnicode(config=True)
|
||||
def _user_changed(self, name, old, new):
|
||||
self.log.name = new
|
||||
cookie_name = Unicode(config=True)
|
||||
hub_prefix = Unicode(config=True)
|
||||
hub_api_url = Unicode(config=True)
|
||||
aliases = aliases
|
||||
open_browser = False
|
||||
trust_xheaders = True
|
||||
login_handler_class = JupyterHubLoginHandler
|
||||
logout_handler_class = JupyterHubLogoutHandler
|
||||
|
||||
cookie_cache_lifetime = Integer(
|
||||
config=True,
|
||||
default_value=300,
|
||||
allow_none=True,
|
||||
help="""
|
||||
Time, in seconds, that we cache a validated cookie before requiring
|
||||
revalidation with the hub.
|
||||
""",
|
||||
)
|
||||
|
||||
def _log_datefmt_default(self):
|
||||
"""Exclude date from default date format"""
|
||||
return "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
def _log_format_default(self):
|
||||
"""override default log format to include time"""
|
||||
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
||||
|
||||
def _confirm_exit(self):
|
||||
# disable the exit confirmation for background notebook processes
|
||||
ioloop.IOLoop.instance().stop()
|
||||
|
||||
def _clear_cookie_cache(self):
|
||||
self.log.debug("Clearing cookie cache")
|
||||
self.tornado_settings['cookie_cache'].clear()
|
||||
|
||||
def start(self):
|
||||
# Start a PeriodicCallback to clear cached cookies. This forces us to
|
||||
# revalidate our user with the Hub at least every
|
||||
# `cookie_cache_lifetime` seconds.
|
||||
if self.cookie_cache_lifetime:
|
||||
ioloop.PeriodicCallback(
|
||||
self._clear_cookie_cache,
|
||||
self.cookie_cache_lifetime * 1e3,
|
||||
).start()
|
||||
super(SingleUserNotebookApp, self).start()
|
||||
|
||||
def init_webapp(self):
|
||||
# load the hub related settings into the tornado settings dict
|
||||
env = os.environ
|
||||
s = self.tornado_settings
|
||||
s['cookie_cache'] = {}
|
||||
s['user'] = self.user
|
||||
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
||||
s['hub_prefix'] = self.hub_prefix
|
||||
s['cookie_name'] = self.cookie_name
|
||||
s['login_url'] = self.hub_prefix
|
||||
s['hub_api_url'] = self.hub_api_url
|
||||
s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
|
||||
|
||||
super(SingleUserNotebookApp, self).init_webapp()
|
||||
self.patch_templates()
|
||||
|
||||
def patch_templates(self):
|
||||
"""Patch page templates to add Hub-related buttons"""
|
||||
env = self.web_app.settings['jinja2_env']
|
||||
|
||||
env.globals['hub_control_panel_url'] = \
|
||||
url_path_join(self.hub_prefix, 'home')
|
||||
|
||||
# patch jinja env loading to modify page template
|
||||
def get_page(name):
|
||||
if name == 'page.html':
|
||||
return page_template
|
||||
|
||||
orig_loader = env.loader
|
||||
env.loader = ChoiceLoader([
|
||||
FunctionLoader(get_page),
|
||||
orig_loader,
|
||||
])
|
||||
|
||||
|
||||
def main():
|
||||
return SingleUserNotebookApp.launch_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -7,26 +7,24 @@ import errno
|
||||
import os
|
||||
import pipes
|
||||
import pwd
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import grp
|
||||
from subprocess import Popen, check_output, PIPE, CalledProcessError
|
||||
from tempfile import TemporaryDirectory
|
||||
import warnings
|
||||
from subprocess import Popen
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from tornado import gen
|
||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||
from tornado.ioloop import PeriodicCallback
|
||||
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import (
|
||||
Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode,
|
||||
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
||||
)
|
||||
|
||||
from .traitlets import Command
|
||||
from .utils import random_port
|
||||
|
||||
NUM_PAT = re.compile(r'\d+')
|
||||
|
||||
class Spawner(LoggingConfigurable):
|
||||
"""Base class for spawning single-user notebook servers.
|
||||
|
||||
@@ -42,38 +40,70 @@ class Spawner(LoggingConfigurable):
|
||||
db = Any()
|
||||
user = Any()
|
||||
hub = Any()
|
||||
authenticator = Any()
|
||||
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"
|
||||
)
|
||||
start_timeout = Integer(60, config=True,
|
||||
).tag(config=True)
|
||||
start_timeout = Integer(60,
|
||||
help="""Timeout (in seconds) before giving up on the spawner.
|
||||
|
||||
This is the timeout for start to return, not the timeout for the server to respond.
|
||||
Callers of spawner.start will assume that startup has failed if it takes longer than this.
|
||||
start should return when the server process is started and its location is known.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
http_timeout = Integer(
|
||||
30, config=True,
|
||||
http_timeout = Integer(30,
|
||||
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
||||
|
||||
Once a server has successfully been spawned, this is the amount of time
|
||||
we wait before assuming that the server is unable to accept
|
||||
connections.
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
poll_interval = Integer(30, config=True,
|
||||
poll_interval = Integer(30,
|
||||
help="""Interval (in seconds) on which to poll the spawner."""
|
||||
)
|
||||
).tag(config=True)
|
||||
_callbacks = List()
|
||||
_poll_callback = Any()
|
||||
|
||||
debug = Bool(False, config=True,
|
||||
debug = Bool(False,
|
||||
help="Enable debug-logging of the single-user server"
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
options_form = Unicode("", help="""
|
||||
An HTML form for options a user can specify on launching their server.
|
||||
The surrounding `<form>` element and the submit button are already provided.
|
||||
|
||||
For example:
|
||||
|
||||
Set your key:
|
||||
<input name="key" val="default_key"></input>
|
||||
<br>
|
||||
Choose a letter:
|
||||
<select name="letter" multiple="true">
|
||||
<option value="A">The letter A</option>
|
||||
<option value="B">The letter B</option>
|
||||
</select>
|
||||
""").tag(config=True)
|
||||
|
||||
def options_from_form(self, form_data):
|
||||
"""Interpret HTTP form data
|
||||
|
||||
Form data will always arrive as a dict of lists of strings.
|
||||
Override this function to understand single-values, numbers, etc.
|
||||
|
||||
This should coerce form data into the structure expected by self.user_options,
|
||||
which must be a dict.
|
||||
|
||||
Instances will receive this data on self.user_options, after passing through this function,
|
||||
prior to `Spawner.start`.
|
||||
"""
|
||||
return form_data
|
||||
|
||||
user_options = Dict(help="This is where form-specified options ultimately end up.")
|
||||
|
||||
env_keep = List([
|
||||
'PATH',
|
||||
@@ -83,31 +113,58 @@ class Spawner(LoggingConfigurable):
|
||||
'VIRTUAL_ENV',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
], config=True,
|
||||
],
|
||||
help="Whitelist of environment variables for the subprocess to inherit"
|
||||
)
|
||||
env = Dict()
|
||||
def _env_default(self):
|
||||
env = {}
|
||||
for key in self.env_keep:
|
||||
if key in os.environ:
|
||||
env[key] = os.environ[key]
|
||||
env['JPY_API_TOKEN'] = self.api_token
|
||||
return env
|
||||
).tag(config=True)
|
||||
env = Dict(help="""Deprecated: use Spawner.get_env or Spawner.environment
|
||||
|
||||
cmd = Command(['jupyterhub-singleuser'], config=True,
|
||||
- extend Spawner.get_env for adding required env in Spawner subclasses
|
||||
- Spawner.environment for config-specified env
|
||||
""")
|
||||
|
||||
environment = Dict(
|
||||
help="""Environment variables to load for the Spawner.
|
||||
|
||||
Value could be a string or a callable. If it is a callable, it will
|
||||
be called with one parameter, which will be the instance of the spawner
|
||||
in use. It should quickly (without doing much blocking operations) return
|
||||
a string that will be used as the value for the environment variable.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
cmd = Command(['jupyterhub-singleuser'],
|
||||
help="""The command used for starting notebooks."""
|
||||
)
|
||||
args = List(Unicode, config=True,
|
||||
).tag(config=True)
|
||||
args = List(Unicode(),
|
||||
help="""Extra arguments to be passed to the single-user server"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
notebook_dir = Unicode('', config=True,
|
||||
notebook_dir = Unicode('',
|
||||
help="""The notebook directory for the single-user server
|
||||
|
||||
`~` will be expanded to the user's home directory
|
||||
`%U` will be expanded to the user's username
|
||||
"""
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
default_url = Unicode('',
|
||||
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):
|
||||
super(Spawner, self).__init__(**kwargs)
|
||||
@@ -133,7 +190,7 @@ class Spawner(LoggingConfigurable):
|
||||
"""store the state necessary for load_state
|
||||
|
||||
A black box of extra state for custom spawners.
|
||||
Should call `super`.
|
||||
Subclasses should call `super`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -153,6 +210,36 @@ class Spawner(LoggingConfigurable):
|
||||
"""
|
||||
self.api_token = ''
|
||||
|
||||
def get_env(self):
|
||||
"""Return the environment dict to use for the Spawner.
|
||||
|
||||
This applies things like `env_keep`, anything defined in `Spawner.environment`,
|
||||
and adds the API token to the env.
|
||||
|
||||
Use this to access the env in Spawner.start to allow extension in subclasses.
|
||||
"""
|
||||
env = {}
|
||||
if self.env:
|
||||
warnings.warn("Spawner.env is deprecated, found %s" % self.env, DeprecationWarning)
|
||||
env.update(self.env)
|
||||
|
||||
for key in self.env_keep:
|
||||
if key in os.environ:
|
||||
env[key] = os.environ[key]
|
||||
|
||||
# config overrides. If the value is a callable, it will be called with
|
||||
# one parameter - the current spawner instance - and the return value
|
||||
# will be assigned to the environment variable. This will be called at
|
||||
# spawn time.
|
||||
for key, value in self.environment.items():
|
||||
if callable(value):
|
||||
env[key] = value(self)
|
||||
else:
|
||||
env[key] = value
|
||||
|
||||
env['JPY_API_TOKEN'] = self.api_token
|
||||
return env
|
||||
|
||||
def get_args(self):
|
||||
"""Return the arguments to be passed after self.cmd"""
|
||||
args = [
|
||||
@@ -160,15 +247,23 @@ class Spawner(LoggingConfigurable):
|
||||
'--port=%i' % self.user.server.port,
|
||||
'--cookie-name=%s' % self.user.server.cookie_name,
|
||||
'--base-url=%s' % self.user.server.base_url,
|
||||
'--hub-host=%s' % self.hub.host,
|
||||
'--hub-prefix=%s' % self.hub.server.base_url,
|
||||
'--hub-api-url=%s' % self.hub.api_url,
|
||||
]
|
||||
if self.ip:
|
||||
args.append('--ip=%s' % self.ip)
|
||||
if self.notebook_dir:
|
||||
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
|
||||
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:
|
||||
args.append('--debug')
|
||||
if self.disable_user_config:
|
||||
args.append('--disable-user-config')
|
||||
args.extend(self.args)
|
||||
return args
|
||||
|
||||
@@ -240,21 +335,23 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
self.stop_polling()
|
||||
|
||||
add_callback = IOLoop.current().add_callback
|
||||
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)
|
||||
@gen.coroutine
|
||||
def wait_for_death(self, timeout=10):
|
||||
"""wait for the process to die, up to timeout seconds"""
|
||||
loop = IOLoop.current()
|
||||
for i in range(int(timeout / self.death_interval)):
|
||||
status = yield self.poll()
|
||||
if status is not None:
|
||||
break
|
||||
else:
|
||||
yield gen.Task(loop.add_timeout, loop.time() + self.death_interval)
|
||||
yield gen.sleep(self.death_interval)
|
||||
|
||||
def _try_setcwd(path):
|
||||
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
||||
@@ -262,12 +359,13 @@ def _try_setcwd(path):
|
||||
try:
|
||||
os.chdir(path)
|
||||
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)
|
||||
path, _ = os.path.split(path)
|
||||
else:
|
||||
return
|
||||
print("Couldn't set CWD at all (%s), using temp dir" % e, file=sys.stderr)
|
||||
td = TemporaryDirectory().name
|
||||
print("Couldn't set CWD at all (%s), using temp dir" % exc, file=sys.stderr)
|
||||
td = mkdtemp()
|
||||
os.chdir(td)
|
||||
|
||||
|
||||
@@ -280,9 +378,6 @@ def set_user_setuid(username):
|
||||
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
||||
|
||||
def preexec():
|
||||
# don't forward signals
|
||||
os.setpgrp()
|
||||
|
||||
# set the user and group
|
||||
os.setgid(gid)
|
||||
try:
|
||||
@@ -298,17 +393,22 @@ def set_user_setuid(username):
|
||||
|
||||
|
||||
class LocalProcessSpawner(Spawner):
|
||||
"""A Spawner that just uses Popen to start local processes."""
|
||||
"""A Spawner that just uses Popen to start local processes as users.
|
||||
|
||||
INTERRUPT_TIMEOUT = Integer(10, config=True,
|
||||
Requires users to exist on the local system.
|
||||
|
||||
This is the default spawner for JupyterHub.
|
||||
"""
|
||||
|
||||
INTERRUPT_TIMEOUT = Integer(10,
|
||||
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
|
||||
)
|
||||
TERM_TIMEOUT = Integer(5, config=True,
|
||||
).tag(config=True)
|
||||
TERM_TIMEOUT = Integer(5,
|
||||
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
|
||||
)
|
||||
KILL_TIMEOUT = Integer(5, config=True,
|
||||
).tag(config=True)
|
||||
KILL_TIMEOUT = Integer(5,
|
||||
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
||||
)
|
||||
).tag(config=True)
|
||||
|
||||
proc = Instance(Popen, allow_none=True)
|
||||
pid = Integer(0)
|
||||
@@ -346,9 +446,11 @@ class LocalProcessSpawner(Spawner):
|
||||
env['SHELL'] = shell
|
||||
return env
|
||||
|
||||
def _env_default(self):
|
||||
env = super()._env_default()
|
||||
return self.user_env(env)
|
||||
def get_env(self):
|
||||
"""Add user environment variables"""
|
||||
env = super().get_env()
|
||||
env = self.user_env(env)
|
||||
return env
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
@@ -357,7 +459,7 @@ class LocalProcessSpawner(Spawner):
|
||||
self.user.server.ip = self.ip
|
||||
self.user.server.port = random_port()
|
||||
cmd = []
|
||||
env = self.env.copy()
|
||||
env = self.get_env()
|
||||
|
||||
cmd.extend(self.cmd)
|
||||
cmd.extend(self.get_args())
|
||||
@@ -365,6 +467,7 @@ class LocalProcessSpawner(Spawner):
|
||||
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
||||
self.proc = Popen(cmd, env=env,
|
||||
preexec_fn=self.make_preexec_fn(self.user.name),
|
||||
start_new_session=True, # don't forward signals
|
||||
)
|
||||
self.pid = self.proc.pid
|
||||
|
||||
@@ -441,5 +544,5 @@ class LocalProcessSpawner(Spawner):
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
# it all failed, zombie process
|
||||
self.log.warn("Process %i never died", self.pid)
|
||||
self.log.warning("Process %i never died", self.pid)
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""mock utilities for testing"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from tempfile import NamedTemporaryFile
|
||||
import threading
|
||||
|
||||
@@ -13,21 +13,26 @@ from tornado import gen
|
||||
from tornado.concurrent import Future
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from traitlets import default
|
||||
|
||||
from ..app import JupyterHub
|
||||
from ..auth import PAMAuthenticator
|
||||
from .. import orm
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import url_path_join
|
||||
|
||||
from pamela import PAMError
|
||||
|
||||
def mock_authenticate(username, password, service='login'):
|
||||
# mimic simplepam's failure to handle unicode
|
||||
if isinstance(username, str):
|
||||
return False
|
||||
if isinstance(password, str):
|
||||
return False
|
||||
|
||||
# just use equality for testing
|
||||
if password == username:
|
||||
return True
|
||||
else:
|
||||
raise PAMError("Fake")
|
||||
|
||||
|
||||
def mock_open_session(username, service):
|
||||
pass
|
||||
|
||||
|
||||
class MockSpawner(LocalProcessSpawner):
|
||||
@@ -41,7 +46,7 @@ class MockSpawner(LocalProcessSpawner):
|
||||
|
||||
def user_env(self, env):
|
||||
return env
|
||||
|
||||
@default('cmd')
|
||||
def _cmd_default(self):
|
||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||
|
||||
@@ -51,18 +56,19 @@ class SlowSpawner(MockSpawner):
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
||||
yield super().start()
|
||||
yield gen.sleep(2)
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
||||
yield gen.sleep(2)
|
||||
yield super().stop()
|
||||
|
||||
|
||||
class NeverSpawner(MockSpawner):
|
||||
"""A spawner that will never start"""
|
||||
|
||||
@default('start_timeout')
|
||||
def _start_timeout_default(self):
|
||||
return 1
|
||||
|
||||
@@ -71,7 +77,23 @@ class NeverSpawner(MockSpawner):
|
||||
return Future()
|
||||
|
||||
|
||||
class FormSpawner(MockSpawner):
|
||||
options_form = "IMAFORM"
|
||||
|
||||
def options_from_form(self, form_data):
|
||||
options = {}
|
||||
options['notspecified'] = 5
|
||||
if 'bounds' in form_data:
|
||||
options['bounds'] = [int(i) for i in form_data['bounds']]
|
||||
if 'energy' in form_data:
|
||||
options['energy'] = form_data['energy'][0]
|
||||
if 'hello_file' in form_data:
|
||||
options['hello'] = form_data['hello_file'][0]
|
||||
return options
|
||||
|
||||
|
||||
class MockPAMAuthenticator(PAMAuthenticator):
|
||||
@default('admin_users')
|
||||
def _admin_users_default(self):
|
||||
return {'admin'}
|
||||
|
||||
@@ -80,20 +102,34 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
||||
return not user.name.startswith('dne')
|
||||
|
||||
def authenticate(self, *args, **kwargs):
|
||||
with mock.patch('simplepam.authenticate', mock_authenticate):
|
||||
with mock.patch.multiple('pamela',
|
||||
authenticate=mock_authenticate,
|
||||
open_session=mock_open_session,
|
||||
close_session=mock_open_session,
|
||||
):
|
||||
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
||||
|
||||
class MockHub(JupyterHub):
|
||||
"""Hub with various mock bits"""
|
||||
|
||||
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):
|
||||
return 'localhost'
|
||||
return '127.0.0.1'
|
||||
|
||||
@default('authenticator_class')
|
||||
def _authenticator_class_default(self):
|
||||
return MockPAMAuthenticator
|
||||
|
||||
@default('spawner_class')
|
||||
def _spawner_class_default(self):
|
||||
return MockSpawner
|
||||
|
||||
@@ -102,7 +138,8 @@ class MockHub(JupyterHub):
|
||||
|
||||
def start(self, argv=None):
|
||||
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()
|
||||
|
||||
@@ -139,13 +176,33 @@ class MockHub(JupyterHub):
|
||||
self.db_file.close()
|
||||
|
||||
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={
|
||||
'username': name,
|
||||
'password': name,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert 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,17 +2,18 @@
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from queue import Queue
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
import requests
|
||||
|
||||
from tornado import gen
|
||||
|
||||
from ..utils import url_path_join as ujoin
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..utils import url_path_join as ujoin
|
||||
from . import mocking
|
||||
from .mocking import public_url, user_url
|
||||
|
||||
|
||||
def check_db_locks(func):
|
||||
@@ -41,11 +42,15 @@ def check_db_locks(func):
|
||||
def find_user(db, name):
|
||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||
|
||||
def add_user(db, **kwargs):
|
||||
user = orm.User(**kwargs)
|
||||
db.add(user)
|
||||
def add_user(db, app=None, **kwargs):
|
||||
orm_user = orm.User(**kwargs)
|
||||
db.add(orm_user)
|
||||
db.commit()
|
||||
if app:
|
||||
user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
|
||||
return user
|
||||
else:
|
||||
return orm_user
|
||||
|
||||
def auth_header(db, name):
|
||||
user = find_user(db, name)
|
||||
@@ -100,7 +105,7 @@ def test_auth_api(app):
|
||||
|
||||
|
||||
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
|
||||
user = find_user(app.db, 'admin')
|
||||
if user is None:
|
||||
@@ -204,6 +209,17 @@ def test_add_multi_user_bad(app):
|
||||
r = api_request(app, 'users', method='post', data='[]')
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_add_multi_user_invalid(app):
|
||||
app.authenticator.username_pattern = r'w.*'
|
||||
r = api_request(app, 'users', method='post',
|
||||
data=json.dumps({'usernames': ['Willow', 'Andrew', 'Tara']})
|
||||
)
|
||||
app.authenticator.username_pattern = ''
|
||||
assert r.status_code == 400
|
||||
assert r.json()['message'] == 'Invalid usernames: andrew, tara'
|
||||
|
||||
|
||||
def test_add_multi_user(app):
|
||||
db = app.db
|
||||
names = ['a', 'b']
|
||||
@@ -310,35 +326,45 @@ def get_app_user(app, name):
|
||||
No ORM methods should be called on the result.
|
||||
"""
|
||||
q = Queue()
|
||||
def get_user():
|
||||
def get_user_id():
|
||||
user = find_user(app.db, name)
|
||||
q.put(user)
|
||||
app.io_loop.add_callback(get_user)
|
||||
return q.get(timeout=2)
|
||||
q.put(user.id)
|
||||
app.io_loop.add_callback(get_user_id)
|
||||
user_id = q.get(timeout=2)
|
||||
return app.users[user_id]
|
||||
|
||||
def test_spawn(app, io_loop):
|
||||
db = app.db
|
||||
name = 'wash'
|
||||
user = add_user(db, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
user = add_user(db, app=app, name=name)
|
||||
options = {
|
||||
's': ['value'],
|
||||
'i': 5,
|
||||
}
|
||||
r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options))
|
||||
assert r.status_code == 201
|
||||
assert 'pid' in user.state
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawner.user_options == options
|
||||
assert not app_user.spawn_pending
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
assert status is None
|
||||
|
||||
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.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
|
||||
argv = r.json()
|
||||
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
|
||||
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')
|
||||
assert r.status_code == 204
|
||||
@@ -348,14 +374,16 @@ def test_spawn(app, io_loop):
|
||||
assert status == 0
|
||||
|
||||
def test_slow_spawn(app, io_loop):
|
||||
app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
|
||||
# app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
|
||||
app.tornado_settings['spawner_class'] = mocking.SlowSpawner
|
||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
||||
app.tornado_application.settings['slow_stop_timeout'] = 0
|
||||
|
||||
db = app.db
|
||||
name = 'zoe'
|
||||
user = add_user(db, name=name)
|
||||
user = add_user(db, app=app, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
app.tornado_settings['spawner_class'] = mocking.MockSpawner
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 202
|
||||
app_user = get_app_user(app, name)
|
||||
@@ -363,11 +391,10 @@ def test_slow_spawn(app, io_loop):
|
||||
assert app_user.spawn_pending
|
||||
assert not app_user.stop_pending
|
||||
|
||||
dt = timedelta(seconds=0.1)
|
||||
@gen.coroutine
|
||||
def wait_spawn():
|
||||
while app_user.spawn_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
io_loop.run_sync(wait_spawn)
|
||||
assert not app_user.spawn_pending
|
||||
@@ -377,7 +404,7 @@ def test_slow_spawn(app, io_loop):
|
||||
@gen.coroutine
|
||||
def wait_stop():
|
||||
while app_user.stop_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
r.raise_for_status()
|
||||
@@ -399,22 +426,22 @@ def test_slow_spawn(app, io_loop):
|
||||
|
||||
|
||||
def test_never_spawn(app, io_loop):
|
||||
app.tornado_application.settings['spawner_class'] = mocking.NeverSpawner
|
||||
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
||||
|
||||
db = app.db
|
||||
name = 'badger'
|
||||
user = add_user(db, name=name)
|
||||
user = add_user(db, app=app, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
app.tornado_settings['spawner_class'] = mocking.MockSpawner
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawn_pending
|
||||
|
||||
dt = timedelta(seconds=0.1)
|
||||
@gen.coroutine
|
||||
def wait_pending():
|
||||
while app_user.spawn_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
io_loop.run_sync(wait_pending)
|
||||
assert not app_user.spawn_pending
|
||||
@@ -429,6 +456,76 @@ def test_get_proxy(app, io_loop):
|
||||
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):
|
||||
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||
'servers': True,
|
||||
|
@@ -1,11 +1,17 @@
|
||||
"""Test the JupyterHub entry point"""
|
||||
|
||||
import binascii
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from getpass import getuser
|
||||
from subprocess import check_output
|
||||
from subprocess import check_output, Popen, PIPE
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from .mocking import MockHub
|
||||
from .. import orm
|
||||
|
||||
def test_help_all():
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
|
||||
@@ -16,16 +22,31 @@ def test_token_app():
|
||||
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
||||
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
||||
with TemporaryDirectory() as td:
|
||||
out = check_output(cmd + [getuser()], cwd=td).decode('utf8', 'replace').strip()
|
||||
with open(os.path.join(td, 'jupyterhub_config.py'), 'w') as f:
|
||||
f.write("c.Authenticator.admin_users={'user'}")
|
||||
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
|
||||
assert re.match(r'^[a-z0-9]+$', out)
|
||||
|
||||
def test_generate_config():
|
||||
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
|
||||
cfg_file = tf.name
|
||||
with open(cfg_file, 'w') as f:
|
||||
f.write("c.A = 5")
|
||||
p = Popen([sys.executable, '-m', 'jupyterhub',
|
||||
'--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'
|
||||
|
||||
out = check_output([sys.executable, '-m', 'jupyterhub',
|
||||
'--generate-config', '-f', cfg_file]
|
||||
).decode('utf8', 'replace')
|
||||
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)
|
||||
with open(cfg_file) as f:
|
||||
cfg_text = f.read()
|
||||
@@ -33,3 +54,89 @@ def test_generate_config():
|
||||
assert cfg_file in out
|
||||
assert 'Spawner.cmd' in cfg_text
|
||||
assert 'Authenticator.whitelist' in cfg_text
|
||||
|
||||
|
||||
def test_init_tokens():
|
||||
with TemporaryDirectory() as td:
|
||||
db_file = os.path.join(td, 'jupyterhub.sqlite')
|
||||
tokens = {
|
||||
'super-secret-token': 'alyx',
|
||||
'also-super-secret': 'gordon',
|
||||
'boagasdfasdf': 'chell',
|
||||
}
|
||||
app = MockHub(db_file=db_file, api_tokens=tokens)
|
||||
app.initialize([])
|
||||
db = app.db
|
||||
for token, username in tokens.items():
|
||||
api_token = orm.APIToken.find(db, token)
|
||||
assert api_token is not None
|
||||
user = api_token.user
|
||||
assert user.name == username
|
||||
|
||||
# simulate second startup, reloading same tokens:
|
||||
app = MockHub(db_file=db_file, api_tokens=tokens)
|
||||
app.initialize([])
|
||||
db = app.db
|
||||
for token, username in tokens.items():
|
||||
api_token = orm.APIToken.find(db, token)
|
||||
assert api_token is not None
|
||||
user = api_token.user
|
||||
assert user.name == username
|
||||
|
||||
# don't allow failed token insertion to create users:
|
||||
tokens['short'] = 'gman'
|
||||
app = MockHub(db_file=db_file, api_tokens=tokens)
|
||||
# with pytest.raises(ValueError):
|
||||
app.initialize([])
|
||||
assert orm.User.find(app.db, 'gman') is None
|
||||
|
||||
|
||||
def test_write_cookie_secret(tmpdir):
|
||||
secret_path = str(tmpdir.join('cookie_secret'))
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
hub.init_secrets()
|
||||
assert os.path.exists(secret_path)
|
||||
assert os.stat(secret_path).st_mode & 0o600
|
||||
assert not os.stat(secret_path).st_mode & 0o177
|
||||
|
||||
|
||||
def test_cookie_secret_permissions(tmpdir):
|
||||
secret_file = tmpdir.join('cookie_secret')
|
||||
secret_path = str(secret_file)
|
||||
secret = os.urandom(1024)
|
||||
secret_file.write(binascii.b2a_base64(secret))
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
|
||||
# raise with public secret file
|
||||
os.chmod(secret_path, 0o664)
|
||||
with pytest.raises(SystemExit):
|
||||
hub.init_secrets()
|
||||
|
||||
# ok with same file, proper permissions
|
||||
os.chmod(secret_path, 0o660)
|
||||
hub.init_secrets()
|
||||
assert hub.cookie_secret == secret
|
||||
|
||||
|
||||
def test_cookie_secret_content(tmpdir):
|
||||
secret_file = tmpdir.join('cookie_secret')
|
||||
secret_file.write('not base 64: uñiço∂e')
|
||||
secret_path = str(secret_file)
|
||||
os.chmod(secret_path, 0o660)
|
||||
hub = MockHub(cookie_secret_file=secret_path)
|
||||
with pytest.raises(SystemExit):
|
||||
hub.init_secrets()
|
||||
|
||||
|
||||
def test_cookie_secret_env(tmpdir):
|
||||
hub = MockHub(cookie_secret_file=str(tmpdir.join('cookie_secret')))
|
||||
|
||||
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'not hex'}):
|
||||
with pytest.raises(ValueError):
|
||||
hub.init_secrets()
|
||||
|
||||
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'abc123'}):
|
||||
hub.init_secrets()
|
||||
assert hub.cookie_secret == binascii.a2b_hex('abc123')
|
||||
assert not os.path.exists(hub.cookie_secret_file)
|
||||
|
||||
|
@@ -3,7 +3,6 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from subprocess import CalledProcessError
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -13,13 +12,13 @@ from jupyterhub import auth, orm
|
||||
|
||||
def test_pam_auth(io_loop):
|
||||
authenticator = MockPAMAuthenticator()
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'match',
|
||||
'password': 'match',
|
||||
}))
|
||||
assert authorized == 'match'
|
||||
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'match',
|
||||
'password': 'nomatch',
|
||||
}))
|
||||
@@ -27,19 +26,19 @@ def test_pam_auth(io_loop):
|
||||
|
||||
def test_pam_auth_whitelist(io_loop):
|
||||
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'kaylee',
|
||||
'password': 'kaylee',
|
||||
}))
|
||||
assert authorized == 'kaylee'
|
||||
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'wash',
|
||||
'password': 'nomatch',
|
||||
}))
|
||||
assert authorized is None
|
||||
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'mal',
|
||||
'password': 'mal',
|
||||
}))
|
||||
@@ -59,14 +58,14 @@ def test_pam_auth_group_whitelist(io_loop):
|
||||
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
||||
|
||||
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'kaylee',
|
||||
'password': 'kaylee',
|
||||
}))
|
||||
assert authorized == 'kaylee'
|
||||
|
||||
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'mal',
|
||||
'password': 'mal',
|
||||
}))
|
||||
@@ -75,7 +74,7 @@ def test_pam_auth_group_whitelist(io_loop):
|
||||
|
||||
def test_pam_auth_no_such_group(io_loop):
|
||||
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||
'username': 'kaylee',
|
||||
'password': 'kaylee',
|
||||
}))
|
||||
@@ -93,33 +92,47 @@ def test_wont_add_system_user(io_loop):
|
||||
def test_cant_add_system_user(io_loop):
|
||||
user = orm.User(name='lioness4321')
|
||||
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||
authenticator.add_user_cmd = ['jupyterhub-fake-command']
|
||||
authenticator.create_system_users = True
|
||||
|
||||
def check_output(cmd, *a, **kw):
|
||||
raise CalledProcessError(1, cmd)
|
||||
class DummyFile:
|
||||
def read(self):
|
||||
return b'dummy error'
|
||||
|
||||
with mock.patch.object(auth, 'check_output', check_output):
|
||||
with pytest.raises(RuntimeError):
|
||||
class DummyPopen:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.returncode = 1
|
||||
self.stdout = DummyFile()
|
||||
|
||||
def wait(self):
|
||||
return
|
||||
|
||||
with mock.patch.object(auth, 'Popen', DummyPopen):
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||
assert str(exc.value) == 'Failed to create system user lioness4321: dummy error'
|
||||
|
||||
|
||||
def test_add_system_user(io_loop):
|
||||
user = orm.User(name='lioness4321')
|
||||
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||
authenticator.create_system_users = True
|
||||
|
||||
def check_output(*a, **kw):
|
||||
return
|
||||
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
|
||||
|
||||
record = {}
|
||||
def check_call(cmd, *a, **kw):
|
||||
class DummyPopen:
|
||||
def __init__(self, cmd, *args, **kwargs):
|
||||
record['cmd'] = cmd
|
||||
self.returncode = 0
|
||||
|
||||
with mock.patch.object(auth, 'check_output', check_output), \
|
||||
mock.patch.object(auth, 'check_call', check_call):
|
||||
def wait(self):
|
||||
return
|
||||
|
||||
with mock.patch.object(auth, 'Popen', DummyPopen):
|
||||
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||
|
||||
assert user.name in record['cmd']
|
||||
assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321']
|
||||
|
||||
|
||||
def test_delete_user(io_loop):
|
||||
@@ -147,3 +160,37 @@ def test_handlers(app):
|
||||
assert handlers[0][0] == '/login'
|
||||
|
||||
|
||||
def test_normalize_names(io_loop):
|
||||
a = MockPAMAuthenticator()
|
||||
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
|
||||
'username': 'ZOE',
|
||||
'password': 'ZOE',
|
||||
}))
|
||||
assert authorized == 'zoe'
|
||||
|
||||
|
||||
def test_username_map(io_loop):
|
||||
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
|
||||
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
|
||||
'username': 'WASH',
|
||||
'password': 'WASH',
|
||||
}))
|
||||
|
||||
assert authorized == 'alpha'
|
||||
|
||||
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
|
||||
'username': 'Inara',
|
||||
'password': 'Inara',
|
||||
}))
|
||||
assert authorized == 'inara'
|
||||
|
||||
|
||||
def test_validate_names(io_loop):
|
||||
a = auth.PAMAuthenticator()
|
||||
assert a.validate_username('willow')
|
||||
assert a.validate_username('giles')
|
||||
a = auth.PAMAuthenticator(username_pattern='w.*')
|
||||
assert not a.validate_username('xander')
|
||||
assert a.validate_username('willow')
|
||||
|
||||
|
||||
|
@@ -7,6 +7,7 @@ import pytest
|
||||
from tornado import gen
|
||||
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from .mocking import MockSpawner
|
||||
|
||||
|
||||
@@ -19,7 +20,7 @@ def test_server(db):
|
||||
assert server.proto == 'http'
|
||||
assert isinstance(server.port, int)
|
||||
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.bind_url == 'http://*:%i/' % server.port
|
||||
server.ip = '127.0.0.1'
|
||||
@@ -92,10 +93,20 @@ def test_tokens(db):
|
||||
found = orm.APIToken.find(db, 'something else')
|
||||
assert found is None
|
||||
|
||||
secret = 'super-secret-preload-token'
|
||||
token = user.new_api_token(secret)
|
||||
assert token == secret
|
||||
assert len(user.api_tokens) == 3
|
||||
|
||||
# raise ValueError on collision
|
||||
with pytest.raises(ValueError):
|
||||
user.new_api_token(token)
|
||||
assert len(user.api_tokens) == 3
|
||||
|
||||
|
||||
def test_spawn_fails(db, io_loop):
|
||||
user = orm.User(name='aeofel')
|
||||
db.add(user)
|
||||
orm_user = orm.User(name='aeofel')
|
||||
db.add(orm_user)
|
||||
db.commit()
|
||||
|
||||
class BadSpawner(MockSpawner):
|
||||
@@ -103,8 +114,13 @@ def test_spawn_fails(db, io_loop):
|
||||
def start(self):
|
||||
raise RuntimeError("Split the party")
|
||||
|
||||
user = User(orm_user, {
|
||||
'spawner_class': BadSpawner,
|
||||
'config': None,
|
||||
})
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
io_loop.run_sync(lambda : user.spawn(BadSpawner))
|
||||
io_loop.run_sync(user.spawn)
|
||||
assert user.server is None
|
||||
assert not user.running
|
||||
|
||||
|
@@ -1,13 +1,18 @@
|
||||
"""Tests for HTML pages"""
|
||||
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from ..utils import url_path_join as ujoin
|
||||
from .. import orm
|
||||
|
||||
import mock
|
||||
from .mocking import FormSpawner, public_url, public_host, user_url
|
||||
from .test_api import api_request
|
||||
|
||||
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)
|
||||
return requests.get(ujoin(base_url, path), **kw)
|
||||
|
||||
@@ -16,15 +21,17 @@ def test_root_no_auth(app, io_loop):
|
||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||
print(routes)
|
||||
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()
|
||||
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
||||
assert r.url == ujoin(url, app.hub.server.base_url, 'login')
|
||||
|
||||
def test_root_auth(app):
|
||||
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()
|
||||
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):
|
||||
r = get_page('home', app, allow_redirects=False)
|
||||
@@ -56,3 +63,184 @@ def test_admin(app):
|
||||
r.raise_for_status()
|
||||
assert r.url.endswith('/admin')
|
||||
|
||||
|
||||
def test_spawn_redirect(app, io_loop):
|
||||
name = 'wash'
|
||||
cookies = app.login_user(name)
|
||||
u = app.users[orm.User.find(app.db, name)]
|
||||
|
||||
# ensure wash's server isn't running:
|
||||
r = api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
|
||||
r.raise_for_status()
|
||||
status = io_loop.run_sync(u.spawner.poll)
|
||||
assert status is not None
|
||||
|
||||
# test spawn page when no server is running
|
||||
r = get_page('spawn', app, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == '/user/%s' % name
|
||||
|
||||
# should have started server
|
||||
status = io_loop.run_sync(u.spawner.poll)
|
||||
assert status is None
|
||||
|
||||
# test spawn page when server is already running (just redirect)
|
||||
r = get_page('spawn', app, cookies=cookies)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == '/user/%s' % name
|
||||
|
||||
def test_spawn_page(app):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
cookies = app.login_user('jones')
|
||||
r = get_page('spawn', app, cookies=cookies)
|
||||
assert r.url.endswith('/spawn')
|
||||
assert FormSpawner.options_form in r.text
|
||||
|
||||
def test_spawn_form(app, io_loop):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(public_url(app), app.hub.server.base_url)
|
||||
cookies = app.login_user('jones')
|
||||
orm_u = orm.User.find(app.db, 'jones')
|
||||
u = app.users[orm_u]
|
||||
io_loop.run_sync(u.stop)
|
||||
|
||||
r = requests.post(ujoin(base_url, 'spawn'), cookies=cookies, data={
|
||||
'bounds': ['-1', '1'],
|
||||
'energy': '511keV',
|
||||
})
|
||||
r.raise_for_status()
|
||||
print(u.spawner)
|
||||
print(u.spawner.user_options)
|
||||
assert u.spawner.user_options == {
|
||||
'energy': '511keV',
|
||||
'bounds': [-1, 1],
|
||||
'notspecified': 5,
|
||||
}
|
||||
|
||||
def test_spawn_form_with_file(app, io_loop):
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(public_url(app), app.hub.server.base_url)
|
||||
cookies = app.login_user('jones')
|
||||
orm_u = orm.User.find(app.db, 'jones')
|
||||
u = app.users[orm_u]
|
||||
io_loop.run_sync(u.stop)
|
||||
|
||||
r = requests.post(ujoin(base_url, 'spawn'),
|
||||
cookies=cookies,
|
||||
data={
|
||||
'bounds': ['-1', '1'],
|
||||
'energy': '511keV',
|
||||
},
|
||||
files={'hello': ('hello.txt', b'hello world\n')}
|
||||
)
|
||||
r.raise_for_status()
|
||||
print(u.spawner)
|
||||
print(u.spawner.user_options)
|
||||
assert u.spawner.user_options == {
|
||||
'energy': '511keV',
|
||||
'bounds': [-1, 1],
|
||||
'notspecified': 5,
|
||||
'hello': {'filename': 'hello.txt',
|
||||
'body': b'hello world\n',
|
||||
'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
|
||||
from queue import Queue
|
||||
from subprocess import Popen
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .. import orm
|
||||
from .mocking import MockHub
|
||||
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
|
||||
'--api-port', str(proxy_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)
|
||||
def _cleanup_proxy():
|
||||
if proxy.poll() is None:
|
||||
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
|
||||
r.raise_for_status()
|
||||
|
||||
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
|
||||
proxy.terminate()
|
||||
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
||||
|
||||
# check that the routes are correct
|
||||
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
|
||||
proxy.terminate()
|
||||
@@ -90,13 +97,16 @@ def test_external_proxy(request, io_loop):
|
||||
'--api-port', str(proxy_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)
|
||||
wait_for_proxy()
|
||||
|
||||
# tell the hub where the new proxy is
|
||||
r = api_request(app, 'proxy', method='patch', data=json.dumps({
|
||||
'port': proxy_port,
|
||||
'protocol': 'http',
|
||||
'ip': app.ip,
|
||||
'auth_token': new_auth_token,
|
||||
}))
|
||||
r.raise_for_status()
|
||||
@@ -113,7 +123,8 @@ def test_external_proxy(request, io_loop):
|
||||
|
||||
# check that the routes are correct
|
||||
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):
|
||||
proxy = app.proxy
|
||||
@@ -123,13 +134,24 @@ def test_check_routes(app, io_loop):
|
||||
r.raise_for_status()
|
||||
zoe = orm.User.find(app.db, 'zoe')
|
||||
assert zoe is not None
|
||||
zoe = app.users[zoe]
|
||||
before = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' in before
|
||||
io_loop.run_sync(app.proxy.check_routes)
|
||||
assert zoe.proxy_path in before
|
||||
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
|
||||
io_loop.run_sync(lambda : proxy.delete_user(zoe))
|
||||
during = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' not in during
|
||||
io_loop.run_sync(app.proxy.check_routes)
|
||||
assert zoe.proxy_path not in during
|
||||
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
|
||||
after = sorted(io_loop.run_sync(app.proxy.get_routes))
|
||||
assert '/user/zoe' in after
|
||||
assert zoe.proxy_path in 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.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from tornado import gen
|
||||
|
||||
from .. import spawner as spawnermod
|
||||
from ..spawner import LocalProcessSpawner
|
||||
@@ -39,13 +44,14 @@ def new_spawner(db, **kwargs):
|
||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||
kwargs.setdefault('poll_interval', 1)
|
||||
return LocalProcessSpawner(db=db, **kwargs)
|
||||
|
||||
|
||||
def test_spawner(db, io_loop):
|
||||
spawner = new_spawner(db)
|
||||
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
|
||||
time.sleep(1)
|
||||
@@ -57,9 +63,9 @@ def test_spawner(db, io_loop):
|
||||
assert status == 1
|
||||
|
||||
def test_single_user_spawner(db, io_loop):
|
||||
spawner = new_spawner(db, cmd=[sys.executable, '-m', 'jupyterhub.singleuser'])
|
||||
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
|
||||
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,
|
||||
# checking for early termination every 1s
|
||||
def wait():
|
||||
@@ -110,3 +116,53 @@ def test_stop_spawner_stop_now(db, io_loop):
|
||||
status = io_loop.run_sync(spawner.poll)
|
||||
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)
|
||||
if isinstance(default_value, str):
|
||||
default_value = [default_value]
|
||||
super().__init__(Unicode, default_value, **kwargs)
|
||||
super().__init__(Unicode(), default_value, **kwargs)
|
||||
|
||||
def validate(self, obj, value):
|
||||
if isinstance(value, str):
|
||||
|
310
jupyterhub/user.py
Normal file
310
jupyterhub/user.py
Normal file
@@ -0,0 +1,310 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from .utils import url_path_join
|
||||
|
||||
from . import orm
|
||||
from traitlets import HasTraits, Any, Dict, observe, default
|
||||
from .spawner import LocalProcessSpawner
|
||||
|
||||
|
||||
class UserDict(dict):
|
||||
"""Like defaultdict, but for users
|
||||
|
||||
Getting by a user id OR an orm.User instance returns a User wrapper around the orm user.
|
||||
"""
|
||||
def __init__(self, db_factory, settings):
|
||||
self.db_factory = db_factory
|
||||
self.settings = settings
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.db_factory()
|
||||
|
||||
def __contains__(self, key):
|
||||
if isinstance(key, (User, orm.User)):
|
||||
key = key.id
|
||||
return dict.__contains__(self, key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, User):
|
||||
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):
|
||||
# users[orm_user] returns User(orm_user)
|
||||
orm_user = key
|
||||
if orm_user.id not in self:
|
||||
user = self[orm_user.id] = User(orm_user, self.settings)
|
||||
return user
|
||||
user = dict.__getitem__(self, orm_user.id)
|
||||
user.db = self.db
|
||||
return user
|
||||
elif isinstance(key, int):
|
||||
id = key
|
||||
if id not in self:
|
||||
orm_user = self.db.query(orm.User).filter(orm.User.id==id).first()
|
||||
if orm_user is None:
|
||||
raise KeyError("No such user: %s" % id)
|
||||
user = self[id] = User(orm_user, self.settings)
|
||||
return dict.__getitem__(self, id)
|
||||
else:
|
||||
raise KeyError(repr(key))
|
||||
|
||||
def __delitem__(self, key):
|
||||
user = self[key]
|
||||
user_id = user.id
|
||||
db = self.db
|
||||
db.delete(user.orm_user)
|
||||
db.commit()
|
||||
dict.__delitem__(self, user_id)
|
||||
|
||||
|
||||
class User(HasTraits):
|
||||
|
||||
@default('log')
|
||||
def _log_default(self):
|
||||
return app_log
|
||||
|
||||
settings = Dict()
|
||||
|
||||
db = Any(allow_none=True)
|
||||
@default('db')
|
||||
def _db_default(self):
|
||||
if self.orm_user:
|
||||
return inspect(self.orm_user).session
|
||||
@observe('db')
|
||||
def _db_changed(self, change):
|
||||
"""Changing db session reacquires ORM User object"""
|
||||
# db session changed, re-get orm User
|
||||
if self.orm_user:
|
||||
id = self.orm_user.id
|
||||
self.orm_user = change['new'].query(orm.User).filter(orm.User.id==id).first()
|
||||
self.spawner.db = self.db
|
||||
|
||||
orm_user = None
|
||||
spawner = None
|
||||
spawn_pending = False
|
||||
stop_pending = False
|
||||
|
||||
@property
|
||||
def authenticator(self):
|
||||
return self.settings.get('authenticator', None)
|
||||
|
||||
@property
|
||||
def spawner_class(self):
|
||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||
|
||||
def __init__(self, orm_user, settings, **kwargs):
|
||||
self.orm_user = orm_user
|
||||
self.settings = settings
|
||||
super().__init__(**kwargs)
|
||||
|
||||
hub = self.db.query(orm.Hub).first()
|
||||
|
||||
self.cookie_name = '%s-%s' % (hub.server.cookie_name, quote(self.name, safe=''))
|
||||
self.base_url = url_path_join(
|
||||
self.settings.get('base_url', '/'), 'user', self.escaped_name)
|
||||
|
||||
self.spawner = self.spawner_class(
|
||||
user=self,
|
||||
db=self.db,
|
||||
hub=hub,
|
||||
authenticator=self.authenticator,
|
||||
config=self.settings.get('config'),
|
||||
)
|
||||
|
||||
# pass get/setattr to ORM user
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if hasattr(self.orm_user, attr):
|
||||
return getattr(self.orm_user, attr)
|
||||
else:
|
||||
raise AttributeError(attr)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if self.orm_user and hasattr(self.orm_user, attr):
|
||||
setattr(self.orm_user, attr, value)
|
||||
else:
|
||||
super().__setattr__(attr, value)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.orm_user)
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
"""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:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def escaped_name(self):
|
||||
"""My name, escaped for use in URLs, cookies, etc."""
|
||||
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
|
||||
def spawn(self, options=None):
|
||||
"""Start the user's spawner"""
|
||||
db = self.db
|
||||
|
||||
self.server = orm.Server(
|
||||
cookie_name=self.cookie_name,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
db.add(self.server)
|
||||
db.commit()
|
||||
|
||||
api_token = self.new_api_token()
|
||||
db.commit()
|
||||
|
||||
spawner = self.spawner
|
||||
spawner.user_options = options or {}
|
||||
# we are starting a new server, make sure it doesn't restore state
|
||||
spawner.clear_state()
|
||||
spawner.api_token = api_token
|
||||
|
||||
# trigger pre-spawn hook on authenticator
|
||||
authenticator = self.authenticator
|
||||
if (authenticator):
|
||||
yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner))
|
||||
|
||||
self.spawn_pending = True
|
||||
# wait for spawner.start to return
|
||||
try:
|
||||
f = spawner.start()
|
||||
# commit any changes in spawner.start (always commit db changes before yield)
|
||||
db.commit()
|
||||
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
|
||||
except Exception as e:
|
||||
if isinstance(e, gen.TimeoutError):
|
||||
self.log.warning("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e,
|
||||
))
|
||||
e.reason = 'error'
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name,
|
||||
), exc_info=True)
|
||||
# raise original exception
|
||||
raise e
|
||||
spawner.start_polling()
|
||||
|
||||
# store state
|
||||
self.state = spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
db.commit()
|
||||
self.spawn_pending = False
|
||||
try:
|
||||
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
self.log.warning(
|
||||
"{user}'s server never showed up at {url} "
|
||||
"after {http_timeout} seconds. Giving up".format(
|
||||
user=self.name,
|
||||
url=self.server.url,
|
||||
http_timeout=spawner.http_timeout,
|
||||
)
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
e.reason = 'error'
|
||||
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||
user=self.name, url=self.server.url, error=e,
|
||||
))
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
|
||||
user=self.name,
|
||||
), exc_info=True)
|
||||
# raise original TimeoutError
|
||||
raise e
|
||||
return self
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
"""Stop the user's spawner
|
||||
|
||||
and cleanup after it.
|
||||
"""
|
||||
self.spawn_pending = False
|
||||
spawner = self.spawner
|
||||
self.spawner.stop_polling()
|
||||
self.stop_pending = True
|
||||
try:
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
yield self.spawner.stop()
|
||||
spawner.clear_state()
|
||||
self.state = spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
self.server = None
|
||||
self.db.commit()
|
||||
finally:
|
||||
self.stop_pending = False
|
||||
# trigger post-spawner hook on authenticator
|
||||
auth = spawner.authenticator
|
||||
if auth:
|
||||
yield gen.maybe_future(
|
||||
auth.post_spawn_stop(self, spawner)
|
||||
)
|
||||
|
@@ -6,10 +6,12 @@
|
||||
from binascii import b2a_hex
|
||||
import errno
|
||||
import hashlib
|
||||
from hmac import compare_digest
|
||||
import os
|
||||
import socket
|
||||
from threading import Thread
|
||||
import uuid
|
||||
from hmac import compare_digest
|
||||
import warnings
|
||||
|
||||
from tornado import web, gen, ioloop
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
||||
@@ -28,22 +30,32 @@ def random_port():
|
||||
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||
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
|
||||
def wait_for_server(ip, port, timeout=10):
|
||||
"""wait for any server to show up at ip:port"""
|
||||
loop = ioloop.IOLoop.current()
|
||||
tic = loop.time()
|
||||
while loop.time() - tic < timeout:
|
||||
try:
|
||||
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.Task(loop.add_timeout, loop.time() + 0.1)
|
||||
else:
|
||||
if can_connect(ip, port):
|
||||
return
|
||||
else:
|
||||
yield gen.sleep(0.1)
|
||||
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
||||
**locals()
|
||||
))
|
||||
@@ -66,15 +78,15 @@ def wait_for_http_server(url, timeout=10):
|
||||
if e.code != 599:
|
||||
# we expect 599 for no connection,
|
||||
# but 502 or other proxy error is conceivable
|
||||
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
||||
app_log.warning("Server at %s responded with error: %s", url, e.code)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||
return
|
||||
except (OSError, socket.error) as e:
|
||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
||||
app_log.warn("Failed to connect to %s (%s)", url, e)
|
||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
||||
app_log.warning("Failed to connect to %s (%s)", url, e)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
return
|
||||
|
||||
@@ -192,3 +204,4 @@ def url_path_join(*pieces):
|
||||
result = '/'
|
||||
|
||||
return result
|
||||
|
||||
|
@@ -5,8 +5,9 @@
|
||||
|
||||
version_info = (
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
# 'dev',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
11
onbuild/Dockerfile
Normal file
11
onbuild/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# JupyterHub Dockerfile that loads your jupyterhub_config.py
|
||||
#
|
||||
# Adds ONBUILD step to jupyter/jupyterhub to load your juptyerhub_config.py into the image
|
||||
#
|
||||
# Derivative images must have jupyterhub_config.py next to the Dockerfile.
|
||||
|
||||
FROM jupyterhub/jupyterhub
|
||||
|
||||
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
||||
|
||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
10
onbuild/README.md
Normal file
10
onbuild/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# JupyterHub onbuild image
|
||||
|
||||
If you base a Dockerfile on this image:
|
||||
|
||||
FROM juptyerhub/jupyterhub-onbuild:0.6
|
||||
...
|
||||
|
||||
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.
|
||||
|
||||
This is how the `jupyter/jupyterhub` docker image behaved prior to 0.6.
|
5
readthedocs.yml
Normal file
5
readthedocs.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
name: jupyterhub
|
||||
type: sphinx
|
||||
requirements_file: docs/requirements.txt
|
||||
python:
|
||||
version: 3
|
@@ -1,6 +1,6 @@
|
||||
traitlets>=4
|
||||
tornado>=4
|
||||
traitlets>=4.1
|
||||
tornado>=4.1
|
||||
jinja2
|
||||
simplepam
|
||||
sqlalchemy
|
||||
pamela
|
||||
sqlalchemy>=1.0
|
||||
requests
|
||||
|
@@ -1,4 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env python
|
||||
"""Extend regular notebook server to be aware of multiuser things."""
|
||||
|
||||
from jupyterhub.singleuser import main
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
# PY2 Compat
|
||||
from urllib import quote
|
||||
|
||||
import requests
|
||||
from jinja2 import ChoiceLoader, FunctionLoader
|
||||
|
||||
from tornado import ioloop
|
||||
from tornado.web import HTTPError
|
||||
|
||||
try:
|
||||
import notebook
|
||||
except ImportError:
|
||||
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
|
||||
|
||||
from traitlets import (
|
||||
Bool,
|
||||
Integer,
|
||||
Unicode,
|
||||
CUnicode,
|
||||
)
|
||||
|
||||
from notebook.notebookapp import (
|
||||
NotebookApp,
|
||||
aliases as notebook_aliases,
|
||||
flags as notebook_flags,
|
||||
)
|
||||
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,
|
||||
# which authenticate via the central auth server.
|
||||
|
||||
class JupyterHubLoginHandler(LoginHandler):
|
||||
@staticmethod
|
||||
def login_available(settings):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_token(self, cookie_name, encrypted_cookie):
|
||||
"""method for token verification"""
|
||||
cookie_cache = self.settings['cookie_cache']
|
||||
if encrypted_cookie in cookie_cache:
|
||||
# we've seen this token before, don't ask upstream again
|
||||
return cookie_cache[encrypted_cookie]
|
||||
|
||||
hub_api_url = self.settings['hub_api_url']
|
||||
hub_api_key = self.settings['hub_api_key']
|
||||
r = requests.get(url_path_join(
|
||||
hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''),
|
||||
),
|
||||
headers = {'Authorization' : 'token %s' % hub_api_key},
|
||||
)
|
||||
if r.status_code == 404:
|
||||
data = None
|
||||
elif r.status_code == 403:
|
||||
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
|
||||
elif r.status_code >= 500:
|
||||
self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(502, "Failed to check authorization (upstream problem)")
|
||||
elif r.status_code >= 400:
|
||||
self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||
raise HTTPError(500, "Failed to check authorization")
|
||||
else:
|
||||
data = r.json()
|
||||
cookie_cache[encrypted_cookie] = data
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def get_user(self):
|
||||
"""alternative get_current_user to query the central server"""
|
||||
# only allow this to be called once per handler
|
||||
# avoids issues if an error is raised,
|
||||
# since this may be called again when trying to render the error page
|
||||
if hasattr(self, '_cached_user'):
|
||||
return self._cached_user
|
||||
|
||||
self._cached_user = None
|
||||
my_user = self.settings['user']
|
||||
encrypted_cookie = self.get_cookie(self.cookie_name)
|
||||
if encrypted_cookie:
|
||||
auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie)
|
||||
if not auth_data:
|
||||
# treat invalid token the same as no token
|
||||
return None
|
||||
user = auth_data['name']
|
||||
if user == my_user:
|
||||
self._cached_user = user
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
self.log.debug("No token cookie")
|
||||
return None
|
||||
|
||||
|
||||
class JupyterHubLogoutHandler(LogoutHandler):
|
||||
def get(self):
|
||||
self.redirect(
|
||||
self.settings['hub_host'] +
|
||||
url_path_join(self.settings['hub_prefix'], 'logout'))
|
||||
|
||||
|
||||
# register new hub related command-line aliases
|
||||
aliases = dict(notebook_aliases)
|
||||
aliases.update({
|
||||
'user' : 'SingleUserNotebookApp.user',
|
||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_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 = """
|
||||
{% extends "templates/page.html" %}
|
||||
|
||||
{% block header_buttons %}
|
||||
{{super()}}
|
||||
|
||||
<a href='{{hub_control_panel_url}}'
|
||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||
style='margin-right: 4px; margin-left: 2px;'
|
||||
>
|
||||
Control Panel</a>
|
||||
{% 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):
|
||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||
user = CUnicode(config=True)
|
||||
def _user_changed(self, name, old, new):
|
||||
self.log.name = new
|
||||
cookie_name = Unicode(config=True)
|
||||
hub_prefix = Unicode(config=True)
|
||||
hub_host = Unicode(config=True)
|
||||
hub_api_url = Unicode(config=True)
|
||||
aliases = aliases
|
||||
flags = flags
|
||||
open_browser = False
|
||||
trust_xheaders = True
|
||||
login_handler_class = JupyterHubLoginHandler
|
||||
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(
|
||||
config=True,
|
||||
default_value=300,
|
||||
allow_none=True,
|
||||
help="""
|
||||
Time, in seconds, that we cache a validated cookie before requiring
|
||||
revalidation with the hub.
|
||||
""",
|
||||
)
|
||||
|
||||
def _log_datefmt_default(self):
|
||||
"""Exclude date from default date format"""
|
||||
return "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
def _log_format_default(self):
|
||||
"""override default log format to include time"""
|
||||
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
||||
|
||||
def _confirm_exit(self):
|
||||
# disable the exit confirmation for background notebook processes
|
||||
ioloop.IOLoop.instance().stop()
|
||||
|
||||
def _clear_cookie_cache(self):
|
||||
self.log.debug("Clearing cookie cache")
|
||||
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):
|
||||
# Start a PeriodicCallback to clear cached cookies. This forces us to
|
||||
# revalidate our user with the Hub at least every
|
||||
# `cookie_cache_lifetime` seconds.
|
||||
if self.cookie_cache_lifetime:
|
||||
ioloop.PeriodicCallback(
|
||||
self._clear_cookie_cache,
|
||||
self.cookie_cache_lifetime * 1e3,
|
||||
).start()
|
||||
super(SingleUserNotebookApp, self).start()
|
||||
|
||||
def init_webapp(self):
|
||||
# load the hub related settings into the tornado settings dict
|
||||
env = os.environ
|
||||
s = self.tornado_settings
|
||||
s['cookie_cache'] = {}
|
||||
s['user'] = self.user
|
||||
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
||||
s['hub_prefix'] = self.hub_prefix
|
||||
s['hub_host'] = self.hub_host
|
||||
s['cookie_name'] = self.cookie_name
|
||||
s['login_url'] = self.hub_host + self.hub_prefix
|
||||
s['hub_api_url'] = self.hub_api_url
|
||||
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
|
||||
super(SingleUserNotebookApp, self).init_webapp()
|
||||
self.patch_templates()
|
||||
|
||||
def patch_templates(self):
|
||||
"""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.globals['hub_control_panel_url'] = \
|
||||
self.hub_host + url_path_join(self.hub_prefix, 'home')
|
||||
|
||||
# patch jinja env loading to modify page template
|
||||
def get_page(name):
|
||||
if name == 'page.html':
|
||||
return page_template
|
||||
|
||||
orig_loader = env.loader
|
||||
env.loader = ChoiceLoader([
|
||||
FunctionLoader(get_page),
|
||||
orig_loader,
|
||||
])
|
||||
|
||||
|
||||
def main():
|
||||
return SingleUserNotebookApp.launch_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
6
setup.py
6
setup.py
@@ -83,8 +83,8 @@ setup_args = dict(
|
||||
# this will be overridden when bower is run anyway
|
||||
data_files = get_data_files() or ['dummy'],
|
||||
version = ns['__version__'],
|
||||
description = """JupyterHub: A multi-user server for Jupyter notebooks""",
|
||||
long_description = "",
|
||||
description = "JupyterHub: A multi-user server for Jupyter notebooks",
|
||||
long_description = "See https://jupyterhub.readthedocs.org for more info.",
|
||||
author = "Jupyter Development Team",
|
||||
author_email = "jupyter@googlegroups.com",
|
||||
url = "http://jupyter.org",
|
||||
@@ -166,7 +166,7 @@ class Bower(BaseCommand):
|
||||
|
||||
if self.should_run_npm():
|
||||
print("installing build dependencies with npm")
|
||||
check_call(['npm', 'install'], cwd=here)
|
||||
check_call(['npm', 'install', '--progress=false'], cwd=here)
|
||||
os.utime(self.node_modules)
|
||||
|
||||
env = os.environ.copy()
|
||||
|
@@ -152,15 +152,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
});
|
||||
});
|
||||
|
||||
$("#add-user").click(function () {
|
||||
var dialog = $("#add-user-dialog");
|
||||
$("#add-users").click(function () {
|
||||
var dialog = $("#add-users-dialog");
|
||||
dialog.find(".username-input").val('');
|
||||
dialog.find(".admin-checkbox").prop("checked", false);
|
||||
dialog.modal();
|
||||
});
|
||||
|
||||
$("#add-user-dialog").find(".save-button").click(function () {
|
||||
var dialog = $("#add-user-dialog");
|
||||
$("#add-users-dialog").find(".save-button").click(function () {
|
||||
var dialog = $("#add-users-dialog");
|
||||
var lines = dialog.find(".username-input").val().split('\n');
|
||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||
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 () {
|
||||
var dialog = $("#shutdown-hub-dialog");
|
||||
dialog.find("input[type=checkbox]").prop("checked", true);
|
||||
|
@@ -22,18 +22,19 @@
|
||||
<thead>
|
||||
<tr>
|
||||
{% block thead %}
|
||||
{{ th("User (%i)" % users.count(), 'name') }}
|
||||
{{ th("User (%i)" % users|length, 'name') }}
|
||||
{{ th("Admin", 'admin') }}
|
||||
{{ th("Last Seen", 'last_activity') }}
|
||||
{{ th("Running (%i)" % running.count(), 'running', colspan=2) }}
|
||||
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
|
||||
{% endblock thead %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="user-row add-user-row">
|
||||
<td colspan="12">
|
||||
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
||||
<a id="shutdown-hub" class="col-xs-5 col-xs-offset-2 btn btn-danger">Shutdown Hub</a>
|
||||
<a id="add-users" class="col-xs-2 btn btn-default">Add Users</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>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
@@ -71,9 +72,13 @@
|
||||
This operation cannot be undone.
|
||||
{% 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') %}
|
||||
Are you sure you want to shutdown the Hub?
|
||||
You can chose 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:
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" class="shutdown-proxy-checkbox">Shutdown proxy
|
||||
@@ -108,7 +113,7 @@
|
||||
|
||||
{{ user_modal('Edit User') }}
|
||||
|
||||
{{ user_modal('Add User', multi=True) }}
|
||||
{{ user_modal('Add Users', multi=True) }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@@ -17,6 +17,11 @@
|
||||
{{message}}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if message_html %}
|
||||
<p>
|
||||
{{message_html | safe}}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock error_detail %}
|
||||
</div>
|
||||
|
||||
|
@@ -9,8 +9,15 @@
|
||||
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||
{% endif %}
|
||||
<a id="start" class="btn btn-lg btn-success"
|
||||
href="{{base_url}}user/{{user.name}}/"
|
||||
{% if user.running %}
|
||||
href="{{ user.url }}"
|
||||
{% else %}
|
||||
href="{{base_url}}spawn"
|
||||
{% endif %}
|
||||
>
|
||||
{% if not user.running %}
|
||||
Start
|
||||
{% endif %}
|
||||
My Server
|
||||
</a>
|
||||
{% if user.admin %}
|
||||
|
@@ -82,7 +82,7 @@
|
||||
|
||||
<div id="header" class="navbar navbar-static-top">
|
||||
<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 %}
|
||||
|
||||
|
32
share/jupyter/hub/templates/spawn.html
Normal file
32
share/jupyter/hub/templates/spawn.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row text-center">
|
||||
<h1>Spawner options</h1>
|
||||
</div>
|
||||
<div class="row col-sm-offset-2 col-sm-8">
|
||||
{% if error_message %}
|
||||
<p class="spawn-error-msg text-danger">
|
||||
Error: {{error_message}}
|
||||
</p>
|
||||
{% endif %}
|
||||
<form enctype="multipart/form-data" id="spawn_form" action="{{base_url}}spawn" method="post" role="form">
|
||||
{{spawner_options_form | safe}}
|
||||
<br>
|
||||
<input type="submit" value="Spawn" class="btn btn-jupyter">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script type="text/javascript">
|
||||
require(["jquery"], function ($) {
|
||||
// add bootstrap form-control class to inputs
|
||||
$("#spawn_form").find("input, select, textarea, button").addClass("form-control");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@@ -133,6 +133,7 @@ def untag(vs, push=False):
|
||||
v2 = parse_vs(vs)
|
||||
v2.append('dev')
|
||||
v2[1] += 1
|
||||
v2[2] = 0
|
||||
vs2 = unparse_vs(v2)
|
||||
patch_version(vs2, repo_root)
|
||||
with cd(repo_root):
|
||||
@@ -165,15 +166,13 @@ def make_env(*packages):
|
||||
return py
|
||||
|
||||
|
||||
def build_sdist(py, upload=False):
|
||||
def build_sdist(py):
|
||||
"""Build sdists
|
||||
|
||||
Returns the path to the tarball
|
||||
"""
|
||||
with cd(repo_root):
|
||||
cmd = [py, 'setup.py', 'sdist', '--formats=zip,gztar']
|
||||
if upload:
|
||||
cmd.append('upload')
|
||||
run(cmd)
|
||||
|
||||
return glob.glob(pjoin(repo_root, 'dist', '*.tar.gz'))[0]
|
||||
@@ -184,7 +183,12 @@ def sdist(vs, upload=False):
|
||||
clone_repo()
|
||||
tag(vs, push=upload)
|
||||
py = make_env()
|
||||
tarball = build_sdist(py, upload=upload)
|
||||
tarball = build_sdist(py)
|
||||
if upload:
|
||||
with cd(repo_root):
|
||||
install(py, 'twine')
|
||||
run([py, '-m', 'twine', 'upload', 'dist/*'])
|
||||
|
||||
untag(vs, push=upload)
|
||||
return untar(tarball)
|
||||
|
||||
@@ -214,14 +218,10 @@ def untar(tarball):
|
||||
return glob.glob(pjoin(sdist_root, '*'))[0]
|
||||
|
||||
|
||||
def bdist(upload=False):
|
||||
def bdist():
|
||||
"""build a wheel, optionally uploading it"""
|
||||
py = make_env('wheel')
|
||||
cmd = [py, 'setup.py', 'bdist_wheel']
|
||||
if upload:
|
||||
cmd.append('upload')
|
||||
|
||||
run(cmd)
|
||||
run([py, 'setup.py', 'bdist_wheel'])
|
||||
|
||||
|
||||
@task
|
||||
@@ -233,7 +233,10 @@ def release(vs, upload=False):
|
||||
shutil.rmtree(env_root)
|
||||
|
||||
path = sdist(vs, upload=upload)
|
||||
|
||||
print("Working in %r" % path)
|
||||
with cd(path):
|
||||
bdist(upload=upload)
|
||||
bdist()
|
||||
if upload:
|
||||
py = make_env('twine')
|
||||
run([py, '-m', 'twine', 'upload', 'dist/*'])
|
||||
|
||||
|
Reference in New Issue
Block a user