mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
Compare commits
310 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
7d39e6a1a3 | ||
![]() |
0b1aebbbf4 | ||
![]() |
3003c87f02 | ||
![]() |
2c8c88ac3f | ||
![]() |
db994e09d3 | ||
![]() |
357ba23ff3 | ||
![]() |
54c0c276ed | ||
![]() |
baf8bd9e03 | ||
![]() |
e866abe1a0 | ||
![]() |
48fe642c44 | ||
![]() |
c8487c2117 | ||
![]() |
0d6ee3c63c | ||
![]() |
a00abc7a76 | ||
![]() |
02c8855d10 | ||
![]() |
b5877ac546 | ||
![]() |
ea91bed620 | ||
![]() |
3e81e2ebf9 | ||
![]() |
e9e2b17a92 | ||
![]() |
498181d217 | ||
![]() |
9f807a5959 | ||
![]() |
d328015fe8 | ||
![]() |
cbbc0290b9 | ||
![]() |
7477f2f6d1 | ||
![]() |
c03e50b3a2 | ||
![]() |
cfd19c3e61 | ||
![]() |
c289cdfaec | ||
![]() |
7acaf8ce52 | ||
![]() |
e5821e573a | ||
![]() |
0c16fb98f3 | ||
![]() |
b27ef8e4cb | ||
![]() |
6cfd186f06 | ||
![]() |
552859084c | ||
![]() |
6e8a58091e | ||
![]() |
6b0aee2443 | ||
![]() |
8d00ccc506 | ||
![]() |
86e31dffa5 | ||
![]() |
f421d1a6da | ||
![]() |
9112ad0f4a | ||
![]() |
354aeb96af | ||
![]() |
ff1bf7c4c0 | ||
![]() |
1ff659a847 | ||
![]() |
de40310f54 | ||
![]() |
72d9592241 | ||
![]() |
087a93f9ef | ||
![]() |
4d73f4eedb | ||
![]() |
612cc73c3c | ||
![]() |
c9d02382e3 | ||
![]() |
da647397ac | ||
![]() |
546d86e888 | ||
![]() |
36bc07b02e | ||
![]() |
81b13c6660 | ||
![]() |
b0ef2c4c84 | ||
![]() |
38024c65d8 | ||
![]() |
80997c8297 | ||
![]() |
c467c64e01 | ||
![]() |
3fd80f9f3a | ||
![]() |
d4a4d04183 | ||
![]() |
f6a3f371b4 | ||
![]() |
8fb74c8627 | ||
![]() |
fd6e6f1ded | ||
![]() |
74d3740921 | ||
![]() |
1674d2f698 | ||
![]() |
e5d9d136da | ||
![]() |
1d6b16060b | ||
![]() |
cd268af799 | ||
![]() |
bc37c729ff | ||
![]() |
d277951fa7 | ||
![]() |
e4b214536d | ||
![]() |
713f222e19 | ||
![]() |
6b32a5c2d8 | ||
![]() |
5dc38b85eb | ||
![]() |
494e4fe68b | ||
![]() |
778202ada8 | ||
![]() |
6029204383 | ||
![]() |
30eef4d353 | ||
![]() |
b30be43d22 | ||
![]() |
ca1380eb06 | ||
![]() |
491ee38a37 | ||
![]() |
5a9687b02a | ||
![]() |
6b09ff6ef2 | ||
![]() |
bdbb6164d5 | ||
![]() |
2890e27052 | ||
![]() |
43f13086cf | ||
![]() |
e883fccf2b | ||
![]() |
364c648d6f | ||
![]() |
637cc1a7bb | ||
![]() |
6aae4be54d | ||
![]() |
dbc410d6a1 | ||
![]() |
7ed9c9b6c0 | ||
![]() |
ffece0ae79 | ||
![]() |
59fda9632a | ||
![]() |
998fc28c32 | ||
![]() |
34386ba3b7 | ||
![]() |
64c4d00756 | ||
![]() |
04b7056591 | ||
![]() |
d9fc40652d | ||
![]() |
d0b4e5bc2a | ||
![]() |
9372d5f872 | ||
![]() |
ce59815e16 | ||
![]() |
7c5e89faa6 | ||
![]() |
0fe3dab408 | ||
![]() |
789ee44d85 | ||
![]() |
163a4db3ad | ||
![]() |
50d1f78b61 | ||
![]() |
ab0010fa32 | ||
![]() |
1bc8d50261 | ||
![]() |
24fd843c3c | ||
![]() |
cffdf89327 | ||
![]() |
2e53de0459 | ||
![]() |
80531341c0 | ||
![]() |
94a3584620 | ||
![]() |
12a1ec7f57 | ||
![]() |
d13286606a | ||
![]() |
e39e6d2073 | ||
![]() |
904c848bcc | ||
![]() |
038aae7e0a | ||
![]() |
ba81bd4a01 | ||
![]() |
36d62672df | ||
![]() |
ffd334b5ff | ||
![]() |
7701b82f58 | ||
![]() |
7ca96e5c6c | ||
![]() |
3be33f2884 | ||
![]() |
3d6a0c126f | ||
![]() |
39a7feea72 | ||
![]() |
5529774c1d | ||
![]() |
ffb2ba055a | ||
![]() |
2a2f9c0b67 | ||
![]() |
3e89f45954 | ||
![]() |
fad5f5a61d | ||
![]() |
ed94c2d774 | ||
![]() |
77c66d8b27 | ||
![]() |
33a4f31520 | ||
![]() |
3fb2afc2bd | ||
![]() |
8787335b01 | ||
![]() |
376ee29b12 | ||
![]() |
5e16e6f52f | ||
![]() |
ce45fde74a | ||
![]() |
665edb6651 | ||
![]() |
f98c8feaae | ||
![]() |
5100dd29c2 | ||
![]() |
40ae3a5821 | ||
![]() |
da1fe54aee | ||
![]() |
545739472e | ||
![]() |
36bb03dc3f | ||
![]() |
61fa2d9ef2 | ||
![]() |
301560b6f8 | ||
![]() |
5bd649829a | ||
![]() |
eccf2fe5be | ||
![]() |
e82683d14f | ||
![]() |
2455680ab8 | ||
![]() |
827f694589 | ||
![]() |
fa7e230b6e | ||
![]() |
42a8094b20 | ||
![]() |
a898063b83 | ||
![]() |
c0b67770e4 | ||
![]() |
791e527695 | ||
![]() |
a6b79780b3 | ||
![]() |
25bcb6ede4 | ||
![]() |
839bd79bbd |
4
.coveragerc
Normal file
4
.coveragerc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[run]
|
||||||
|
omit =
|
||||||
|
jupyterhub/tests/*
|
||||||
|
jupyterhub/singleuser.py
|
@@ -3,3 +3,4 @@ bench
|
|||||||
jupyterhub_cookie_secret
|
jupyterhub_cookie_secret
|
||||||
jupyterhub.sqlite
|
jupyterhub.sqlite
|
||||||
jupyterhub_config.py
|
jupyterhub_config.py
|
||||||
|
node_modules
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,6 +4,8 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
|
docs/_build
|
||||||
|
.ipynb_checkpoints
|
||||||
# ignore config file at the top-level of the repo
|
# ignore config file at the top-level of the repo
|
||||||
# but not sub-dirs
|
# but not sub-dirs
|
||||||
/jupyterhub_config.py
|
/jupyterhub_config.py
|
||||||
@@ -14,3 +16,6 @@ share/jupyter/hub/static/css/style.min.css
|
|||||||
share/jupyter/hub/static/css/style.min.css.map
|
share/jupyter/hub/static/css/style.min.css.map
|
||||||
*.egg-info
|
*.egg-info
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
language: python
|
language: python
|
||||||
sudo: false
|
sudo: false
|
||||||
python:
|
python:
|
||||||
|
- 3.5
|
||||||
- 3.4
|
- 3.4
|
||||||
- 3.3
|
- 3.3
|
||||||
before_install:
|
before_install:
|
||||||
@@ -10,6 +11,7 @@ before_install:
|
|||||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||||
install:
|
install:
|
||||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||||
- pip install -f travis-wheels/wheelhouse ipython[notebook]
|
|
||||||
script:
|
script:
|
||||||
- py.test jupyterhub
|
- py.test --cov jupyterhub jupyterhub/tests -v
|
||||||
|
after_success:
|
||||||
|
- codecov
|
||||||
|
33
Dockerfile
33
Dockerfile
@@ -5,24 +5,39 @@
|
|||||||
# FROM jupyter/jupyterhub:latest
|
# FROM jupyter/jupyterhub:latest
|
||||||
#
|
#
|
||||||
|
|
||||||
FROM ipython/ipython
|
FROM debian:jessie
|
||||||
|
|
||||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
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-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||||
|
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
|
# install js dependencies
|
||||||
RUN npm install -g configurable-http-proxy
|
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
|
||||||
|
|
||||||
RUN mkdir -p /srv/
|
|
||||||
|
|
||||||
# install jupyterhub
|
|
||||||
ADD requirements.txt /tmp/requirements.txt
|
|
||||||
RUN pip3 install -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
WORKDIR /srv/
|
WORKDIR /srv/
|
||||||
ADD . /srv/jupyterhub
|
ADD . /srv/jupyterhub
|
||||||
WORKDIR /srv/jupyterhub/
|
WORKDIR /srv/jupyterhub/
|
||||||
|
|
||||||
RUN pip3 install .
|
RUN python setup.py js && pip install . && \
|
||||||
|
rm -rf node_modules ~/.cache ~/.npm
|
||||||
|
|
||||||
WORKDIR /srv/jupyterhub/
|
WORKDIR /srv/jupyterhub/
|
||||||
|
|
||||||
|
99
README.md
99
README.md
@@ -1,6 +1,13 @@
|
|||||||
# JupyterHub: A multi-user server for Jupyter notebooks
|
# JupyterHub: A multi-user server for Jupyter notebooks
|
||||||
|
|
||||||
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
Questions, comments? Visit our Google Group:
|
||||||
|
|
||||||
|
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||||
|
[](https://travis-ci.org/jupyter/jupyterhub)
|
||||||
|
[](https://circleci.com/gh/jupyter/jupyterhub)
|
||||||
|
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||||
|
|
||||||
|
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
||||||
|
|
||||||
Three actors:
|
Three actors:
|
||||||
|
|
||||||
@@ -18,48 +25,59 @@ Basic principles:
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
JupyterHub requires IPython >= 3.0 (current master) and Python >= 3.3.
|
JupyterHub requires [IPython](https://ipython.org/install.html) >= 3.0 (current master) and [Python](https://www.python.org/downloads/) >= 3.3.
|
||||||
|
|
||||||
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
|
sudo apt-get install npm nodejs-legacy
|
||||||
|
|
||||||
(The `nodejs-legacy` package installs the `node` executable,
|
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||||
which is required for npm to work on Debian/Ubuntu at this point)
|
required for npm to work on Debian/Ubuntu.)
|
||||||
|
|
||||||
Then install javascript dependencies:
|
Next, install JavaScript dependencies:
|
||||||
|
|
||||||
sudo npm install -g configurable-http-proxy
|
sudo npm install -g configurable-http-proxy
|
||||||
|
|
||||||
|
### (Optional) Installation Prerequisite (pip)
|
||||||
|
|
||||||
|
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
|
## Installation
|
||||||
|
|
||||||
Then you can install the Python package by doing:
|
JupyterHub can be installed with pip:
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip3 install jupyterhub
|
||||||
pip install .
|
|
||||||
|
|
||||||
If you plan to run notebook servers locally, you may also need to install the IPython notebook:
|
If you plan to run notebook servers locally, you may also need to install the
|
||||||
|
Jupyter ~~IPython~~ notebook:
|
||||||
|
|
||||||
pip install "ipython[notebook]"
|
pip3 install 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.
|
|
||||||
|
|
||||||
|
|
||||||
### Development install
|
### Development install
|
||||||
|
|
||||||
For a development install:
|
For a development install, clone the repository and then install from source:
|
||||||
|
|
||||||
pip install -r dev-requirements.txt
|
git clone https://github.com/jupyter/jupyterhub
|
||||||
pip install -e .
|
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:
|
||||||
|
|
||||||
python setup.py js # fetch updated client-side js (changes rarely)
|
npm install
|
||||||
python setup.py css # recompile CSS from LESS sources
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
## Running the server
|
## Running the server
|
||||||
@@ -70,18 +88,24 @@ To start the server, run the command:
|
|||||||
|
|
||||||
and then visit `http://localhost:8000`, and sign in with your unix credentials.
|
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
|
To allow multiple users to sign into the server, you will need to
|
||||||
`jupyterhub` command as a privileged user, such as root.
|
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||||
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server
|
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||||
as a less privileged user, which requires more configuration of the system.
|
describes how to run the server as a *less privileged user*, which requires more
|
||||||
|
configuration of the system.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
See the [getting started document](docs/source/getting-started.md) for the
|
||||||
|
basics of configuring your JupyterHub deployment.
|
||||||
|
|
||||||
### Some examples
|
### Some examples
|
||||||
|
|
||||||
generate a default config file:
|
Generate a default config file:
|
||||||
|
|
||||||
jupyterhub --generate-config
|
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
|
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||||
|
|
||||||
@@ -90,4 +114,21 @@ which should allow plugging into a variety of authentication or process control
|
|||||||
Some examples, meant as illustration and testing of this concept:
|
Some examples, meant as illustration and testing of this concept:
|
||||||
|
|
||||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
||||||
- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
||||||
|
|
||||||
|
# Getting help
|
||||||
|
|
||||||
|
We encourage you to ask questions on the mailing list:
|
||||||
|
|
||||||
|
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||||
|
|
||||||
|
and you may participate in development discussions or get live help on Gitter:
|
||||||
|
|
||||||
|
[](https://gitter.im/jupyter/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)
|
||||||
|
11
circle.yml
Normal file
11
circle.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
machine:
|
||||||
|
services:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
override:
|
||||||
|
- ls
|
||||||
|
|
||||||
|
test:
|
||||||
|
override:
|
||||||
|
- docker build -t jupyter/jupyterhub .
|
@@ -1,2 +1,5 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest
|
codecov
|
||||||
|
pytest-cov
|
||||||
|
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."
|
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
|
||||||
|
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.
|
@@ -11,6 +11,8 @@ One such example is using [GitHub OAuth][].
|
|||||||
Because the username is passed from the Authenticator to the Spawner,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
a custom Authenticator and Spawner are often used together.
|
||||||
|
|
||||||
|
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||||
|
|
||||||
|
|
||||||
## Basics of Authenticators
|
## Basics of Authenticators
|
||||||
|
|
||||||
@@ -61,6 +63,36 @@ For local user authentication (e.g. PAM), this lets you limit which users
|
|||||||
can login.
|
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
|
## OAuth and other non-password logins
|
||||||
|
|
||||||
Some login mechanisms, such as [OAuth][], don't map onto username+password.
|
Some login mechanisms, such as [OAuth][], don't map onto username+password.
|
||||||
@@ -70,9 +102,9 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut
|
|||||||
at [OAuthenticator][].
|
at [OAuthenticator][].
|
||||||
|
|
||||||
|
|
||||||
[Authenticator]: ../jupyterhub/auth.py
|
[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py
|
||||||
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[OAuth]: http://en.wikipedia.org/wiki/OAuth
|
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||||
[OAuthenticator]: https://github.com/jupyter/oauthenticator
|
[OAuthenticator]: https://github.com/jupyter/oauthenticator
|
||||||
|
|
22
docs/source/changelog.md
Normal file
22
docs/source/changelog.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Summary of changes in JupyterHub
|
||||||
|
|
||||||
|
See `git log` for a more detailed summary.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
- 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.0
|
||||||
|
|
||||||
|
- Based on standalone traitlets instead of IPython.utils.traitlets
|
||||||
|
- multiple users in admin panel
|
||||||
|
- Fixes for usernames that require escaping
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
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
|
407
docs/source/getting-started.md
Normal file
407
docs/source/getting-started.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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. An example would be to generate this
|
||||||
|
file as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 1024 > /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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||||
|
```
|
||||||
|
|
||||||
|
For security reasons, this environment variable should only be visible to the Hub.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- TODO: troubleshooting
|
||||||
|
- [Custom Authenticators](./authenticators.html)
|
||||||
|
- [Custom Spawners](./spawners.html)
|
||||||
|
|
||||||
|
|
||||||
|
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
||||||
|
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
||||||
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
@@ -51,7 +51,7 @@ Authentication is customizable via the Authenticator class.
|
|||||||
Authentication can be replaced by any mechanism,
|
Authentication can be replaced by any mechanism,
|
||||||
such as OAuth, Kerberos, etc.
|
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,
|
which requires the server to be run as root,
|
||||||
or at least with access to the PAM service,
|
or at least with access to the PAM service,
|
||||||
which regular users typically do not have
|
which regular users typically do not have
|
||||||
@@ -59,6 +59,8 @@ which regular users typically do not have
|
|||||||
|
|
||||||
[More info on custom Authenticators](authenticators.md).
|
[More info on custom Authenticators](authenticators.md).
|
||||||
|
|
||||||
|
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||||
|
|
||||||
|
|
||||||
### Spawning
|
### Spawning
|
||||||
|
|
||||||
@@ -72,4 +74,4 @@ and needs to be able to take three actions:
|
|||||||
|
|
||||||
[More info on custom Spawners](spawners.md).
|
[More info on custom Spawners](spawners.md).
|
||||||
|
|
||||||
[An example using Docker](https://github.com/jupyter/dockerspawner).
|
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
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 |
80
docs/source/index.rst
Normal file
80
docs/source/index.rst
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.. JupyterHub documentation master file, created by
|
||||||
|
sphinx-quickstart on Mon Jan 4 16:31:09 2016.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
JupyterHub
|
||||||
|
==========
|
||||||
|
|
||||||
|
.. note:: This is the official documentation for JupyterHub. This project is
|
||||||
|
under active development.
|
||||||
|
|
||||||
|
JupyterHub is a multi-user server that manages and proxies multiple instances
|
||||||
|
of the single-user Jupyter notebook server.
|
||||||
|
|
||||||
|
Three actors:
|
||||||
|
|
||||||
|
* multi-user Hub (tornado process)
|
||||||
|
* configurable http proxy (node-http-proxy)
|
||||||
|
* multiple single-user IPython notebook servers (Python/IPython/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: 1
|
||||||
|
:caption: User Documentation
|
||||||
|
|
||||||
|
getting-started
|
||||||
|
howitworks
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Configuration
|
||||||
|
|
||||||
|
authenticators
|
||||||
|
spawners
|
||||||
|
|
||||||
|
.. 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]: ../jupyterhub/spawner.py
|
@@ -1,86 +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
|
|
||||||
|
|
||||||
## 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
|
|
46
examples/spawn-form/jupyterhub_config.py
Normal file
46
examples/spawn-form/jupyterhub_config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Example JuptyerHub 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
|
@@ -1,2 +1,2 @@
|
|||||||
from .version import *
|
from .version import version_info, __version__
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -11,29 +12,31 @@ from ..utils import token_authenticated
|
|||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TokenAPIHandler(APIHandler):
|
class TokenAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
orm_token = orm.APIToken.find(self.db, token)
|
orm_token = orm.APIToken.find(self.db, token)
|
||||||
if orm_token is None:
|
if orm_token is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
|
||||||
'user' : orm_token.user.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
class CookieAPIHandler(APIHandler):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
cookie_value = self.request.body
|
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`")
|
||||||
|
cookie_value = self.request.body
|
||||||
|
else:
|
||||||
|
cookie_value = cookie_value.encode('utf8')
|
||||||
user = self._user_for_cookie(cookie_name, cookie_value)
|
user = self._user_for_cookie(cookie_name, cookie_value)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps(self.user_model(user)))
|
||||||
'user' : user.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/authorizations/cookie/([^/]+)", CookieAPIHandler),
|
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||||
]
|
]
|
||||||
|
@@ -9,8 +9,43 @@ from http.client import responses
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
|
from ..utils import url_path_join
|
||||||
|
|
||||||
class APIHandler(BaseHandler):
|
class APIHandler(BaseHandler):
|
||||||
|
|
||||||
|
def check_referer(self):
|
||||||
|
"""Check Origin for cross-site API requests.
|
||||||
|
|
||||||
|
Copied from WebSocket with changes:
|
||||||
|
|
||||||
|
- allow unspecified host/referer (e.g. scripts)
|
||||||
|
"""
|
||||||
|
host = self.request.headers.get("Host")
|
||||||
|
referer = self.request.headers.get("Referer")
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
return False
|
||||||
|
if not referer:
|
||||||
|
self.log.warn("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",
|
||||||
|
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():
|
||||||
|
return None
|
||||||
|
return super().get_current_user_cookie()
|
||||||
|
|
||||||
def get_json_body(self):
|
def get_json_body(self):
|
||||||
"""Return the body of the request as JSON data."""
|
"""Return the body of the request as JSON data."""
|
||||||
if not self.request.body:
|
if not self.request.body:
|
||||||
@@ -23,7 +58,6 @@ class APIHandler(BaseHandler):
|
|||||||
self.log.error("Couldn't parse JSON", exc_info=True)
|
self.log.error("Couldn't parse JSON", exc_info=True)
|
||||||
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def write_error(self, status_code, **kwargs):
|
def write_error(self, status_code, **kwargs):
|
||||||
"""Write JSON errors instead of HTML"""
|
"""Write JSON errors instead of HTML"""
|
||||||
@@ -47,3 +81,38 @@ class APIHandler(BaseHandler):
|
|||||||
'status': status_code,
|
'status': status_code,
|
||||||
'message': message or status_message,
|
'message': message or status_message,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
def user_model(self, user):
|
||||||
|
model = {
|
||||||
|
'name': user.name,
|
||||||
|
'admin': user.admin,
|
||||||
|
'server': user.server.base_url if user.running else None,
|
||||||
|
'pending': None,
|
||||||
|
'last_activity': user.last_activity.isoformat(),
|
||||||
|
}
|
||||||
|
if user.spawn_pending:
|
||||||
|
model['pending'] = 'spawn'
|
||||||
|
elif user.stop_pending:
|
||||||
|
model['pending'] = 'stop'
|
||||||
|
return model
|
||||||
|
|
||||||
|
_model_types = {
|
||||||
|
'name': str,
|
||||||
|
'admin': bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check_user_model(self, model):
|
||||||
|
if not isinstance(model, dict):
|
||||||
|
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
||||||
|
if not set(model).issubset(set(self._model_types)):
|
||||||
|
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
||||||
|
for key, value in model.items():
|
||||||
|
if not isinstance(value, self._model_types[key]):
|
||||||
|
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
||||||
|
key, self._model_types[key], type(value)
|
||||||
|
))
|
||||||
|
|
||||||
|
def options(self, *args, **kwargs):
|
||||||
|
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
||||||
|
self.finish()
|
||||||
|
|
@@ -58,7 +58,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
if 'auth_token' in model:
|
if 'auth_token' in model:
|
||||||
self.proxy.auth_token = model['auth_token']
|
self.proxy.auth_token = model['auth_token']
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.log.info("Updated proxy at %s", server.url)
|
self.log.info("Updated proxy at %s", server.bind_url)
|
||||||
yield self.proxy.check_routes()
|
yield self.proxy.check_routes()
|
||||||
|
|
||||||
|
|
||||||
|
@@ -11,44 +11,67 @@ from .. import orm
|
|||||||
from ..utils import admin_only
|
from ..utils import admin_only
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
class BaseUserHandler(APIHandler):
|
|
||||||
|
|
||||||
def user_model(self, user):
|
|
||||||
model = {
|
|
||||||
'name': user.name,
|
|
||||||
'admin': user.admin,
|
|
||||||
'server': user.server.base_url if user.running else None,
|
|
||||||
'pending': None,
|
|
||||||
'last_activity': user.last_activity.isoformat(),
|
|
||||||
}
|
|
||||||
if user.spawn_pending:
|
|
||||||
model['pending'] = 'spawn'
|
|
||||||
elif user.stop_pending:
|
|
||||||
model['pending'] = 'stop'
|
|
||||||
return model
|
|
||||||
|
|
||||||
_model_types = {
|
|
||||||
'name': str,
|
|
||||||
'admin': bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _check_user_model(self, model):
|
|
||||||
if not isinstance(model, dict):
|
|
||||||
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
|
||||||
if not set(model).issubset(set(self._model_types)):
|
|
||||||
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
|
||||||
for key, value in model.items():
|
|
||||||
if not isinstance(value, self._model_types[key]):
|
|
||||||
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
|
||||||
key, self._model_types[key], type(value)
|
|
||||||
))
|
|
||||||
|
|
||||||
class UserListAPIHandler(BaseUserHandler):
|
class UserListAPIHandler(APIHandler):
|
||||||
@admin_only
|
@admin_only
|
||||||
def get(self):
|
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 ]
|
data = [ self.user_model(u) for u in users ]
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
@gen.coroutine
|
||||||
|
def post(self):
|
||||||
|
data = self.get_json_body()
|
||||||
|
if not data or not isinstance(data, dict) or not data.get('usernames'):
|
||||||
|
raise web.HTTPError(400, "Must specify at least one user to create")
|
||||||
|
|
||||||
|
usernames = data.pop('usernames')
|
||||||
|
self._check_user_model(data)
|
||||||
|
# admin is set for all users
|
||||||
|
# to create admin and non-admin users requires at least two API requests
|
||||||
|
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)
|
||||||
|
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))
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for name in to_create:
|
||||||
|
user = self.user_from_username(name)
|
||||||
|
if admin:
|
||||||
|
user.admin = True
|
||||||
|
self.db.commit()
|
||||||
|
try:
|
||||||
|
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||||
|
del self.users[user]
|
||||||
|
raise web.HTTPError(400, "Failed to create user %s: %s" % (name, str(e)))
|
||||||
|
else:
|
||||||
|
created.append(user)
|
||||||
|
|
||||||
|
self.write(json.dumps([ self.user_model(u) for u in created ]))
|
||||||
|
self.set_status(201)
|
||||||
|
|
||||||
|
|
||||||
def admin_or_self(method):
|
def admin_or_self(method):
|
||||||
@@ -66,7 +89,7 @@ def admin_or_self(method):
|
|||||||
return method(self, name)
|
return method(self, name)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
class UserAPIHandler(BaseUserHandler):
|
class UserAPIHandler(APIHandler):
|
||||||
|
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
def get(self, name):
|
def get(self, name):
|
||||||
@@ -92,8 +115,8 @@ class UserAPIHandler(BaseUserHandler):
|
|||||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||||
self.db.delete(user)
|
# remove from registry
|
||||||
self.db.commit()
|
del self.users[user]
|
||||||
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
||||||
|
|
||||||
self.write(json.dumps(self.user_model(user)))
|
self.write(json.dumps(self.user_model(user)))
|
||||||
@@ -115,10 +138,8 @@ class UserAPIHandler(BaseUserHandler):
|
|||||||
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
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))
|
yield gen.maybe_future(self.authenticator.delete_user(user))
|
||||||
|
# remove from registry
|
||||||
# remove from the db
|
del self.users[user]
|
||||||
self.db.delete(user)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
self.set_status(204)
|
self.set_status(204)
|
||||||
|
|
||||||
@@ -135,7 +156,7 @@ class UserAPIHandler(BaseUserHandler):
|
|||||||
self.write(json.dumps(self.user_model(user)))
|
self.write(json.dumps(self.user_model(user)))
|
||||||
|
|
||||||
|
|
||||||
class UserServerAPIHandler(BaseUserHandler):
|
class UserServerAPIHandler(APIHandler):
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
@@ -145,7 +166,8 @@ class UserServerAPIHandler(BaseUserHandler):
|
|||||||
if state is None:
|
if state is None:
|
||||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||||
|
|
||||||
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
|
status = 202 if user.spawn_pending else 201
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
|
|
||||||
@@ -165,7 +187,7 @@ class UserServerAPIHandler(BaseUserHandler):
|
|||||||
status = 202 if user.stop_pending else 204
|
status = 202 if user.stop_pending else 204
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
|
|
||||||
class UserAdminAccessAPIHandler(BaseUserHandler):
|
class UserAdminAccessAPIHandler(APIHandler):
|
||||||
"""Grant admins access to single-user servers
|
"""Grant admins access to single-user servers
|
||||||
|
|
||||||
This handler sets the necessary cookie for an admin to login to a single-user server.
|
This handler sets the necessary cookie for an admin to login to a single-user server.
|
||||||
@@ -184,6 +206,7 @@ class UserAdminAccessAPIHandler(BaseUserHandler):
|
|||||||
if not user.running:
|
if not user.running:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||||
self.set_server_cookie(user)
|
self.set_server_cookie(user)
|
||||||
|
current.other_user_cookies.add(name)
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
"""The multi-user notebook application"""
|
"""The multi-user notebook application"""
|
||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from distutils.version import LooseVersion as V
|
from distutils.version import LooseVersion as V
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
@@ -31,15 +32,11 @@ from tornado.ioloop import IOLoop, PeriodicCallback
|
|||||||
from tornado.log import app_log, access_log, gen_log
|
from tornado.log import app_log, access_log, gen_log
|
||||||
from tornado import gen, web
|
from tornado import gen, web
|
||||||
|
|
||||||
import IPython
|
from traitlets import (
|
||||||
if V(IPython.__version__) < V('3.0'):
|
|
||||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
|
||||||
|
|
||||||
from IPython.utils.traitlets import (
|
|
||||||
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
||||||
Type, Set, Instance, Bytes,
|
Type, Set, Instance, Bytes, Float,
|
||||||
)
|
)
|
||||||
from IPython.config import Application, catch_config_error
|
from traitlets.config import Application, catch_config_error
|
||||||
|
|
||||||
here = os.path.dirname(__file__)
|
here = os.path.dirname(__file__)
|
||||||
|
|
||||||
@@ -48,11 +45,12 @@ from . import handlers, apihandlers
|
|||||||
from .handlers.static import CacheControlStaticFilesHandler
|
from .handlers.static import CacheControlStaticFilesHandler
|
||||||
|
|
||||||
from . import orm
|
from . import orm
|
||||||
|
from .user import User, UserDict
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
from .log import CoroutineLogFormatter
|
from .log import CoroutineLogFormatter, log_request
|
||||||
from .traitlets import URLPrefix
|
from .traitlets import URLPrefix, Command
|
||||||
from .utils import (
|
from .utils import (
|
||||||
url_path_join,
|
url_path_join, localhost,
|
||||||
ISO8601_ms, ISO8601_s,
|
ISO8601_ms, ISO8601_s,
|
||||||
)
|
)
|
||||||
# classes for config
|
# classes for config
|
||||||
@@ -126,6 +124,7 @@ class NewToken(Application):
|
|||||||
hub = JupyterHub(parent=self)
|
hub = JupyterHub(parent=self)
|
||||||
hub.load_config_file(hub.config_file)
|
hub.load_config_file(hub.config_file)
|
||||||
hub.init_db()
|
hub.init_db()
|
||||||
|
hub.hub = hub.db.query(orm.Hub).first()
|
||||||
hub.init_users()
|
hub.init_users()
|
||||||
user = orm.User.find(hub.db, self.name)
|
user = orm.User.find(hub.db, self.name)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -138,6 +137,7 @@ class NewToken(Application):
|
|||||||
class JupyterHub(Application):
|
class JupyterHub(Application):
|
||||||
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
||||||
name = 'jupyterhub'
|
name = 'jupyterhub'
|
||||||
|
version = jupyterhub.__version__
|
||||||
|
|
||||||
description = """Start a multi-user Jupyter Notebook server
|
description = """Start a multi-user Jupyter Notebook server
|
||||||
|
|
||||||
@@ -185,6 +185,11 @@ class JupyterHub(Application):
|
|||||||
Useful for daemonizing jupyterhub.
|
Useful for daemonizing jupyterhub.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cookie_max_age_days = Float(14, config=True,
|
||||||
|
help="""Number of days for a login cookie to be valid.
|
||||||
|
Default is two weeks.
|
||||||
|
"""
|
||||||
|
)
|
||||||
last_activity_interval = Integer(300, config=True,
|
last_activity_interval = Integer(300, config=True,
|
||||||
help="Interval (in seconds) at which to update last-activity timestamps."
|
help="Interval (in seconds) at which to update last-activity timestamps."
|
||||||
)
|
)
|
||||||
@@ -195,7 +200,15 @@ class JupyterHub(Application):
|
|||||||
data_files_path = Unicode(DATA_FILES_PATH, config=True,
|
data_files_path = Unicode(DATA_FILES_PATH, config=True,
|
||||||
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
|
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
template_paths = List(
|
||||||
|
config=True,
|
||||||
|
help="Paths to search for jinja templates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _template_paths_default(self):
|
||||||
|
return [os.path.join(self.data_files_path, 'templates')]
|
||||||
|
|
||||||
ssl_key = Unicode('', config=True,
|
ssl_key = Unicode('', config=True,
|
||||||
help="""Path to SSL key file for the public facing interface of the proxy
|
help="""Path to SSL key file for the public facing interface of the proxy
|
||||||
|
|
||||||
@@ -222,7 +235,7 @@ class JupyterHub(Application):
|
|||||||
help="Supply extra arguments that will be passed to Jinja environment."
|
help="Supply extra arguments that will be passed to Jinja environment."
|
||||||
)
|
)
|
||||||
|
|
||||||
proxy_cmd = Unicode('configurable-http-proxy', config=True,
|
proxy_cmd = Command('configurable-http-proxy', config=True,
|
||||||
help="""The command to start the http proxy.
|
help="""The command to start the http proxy.
|
||||||
|
|
||||||
Only override if configurable-http-proxy is not on your PATH
|
Only override if configurable-http-proxy is not on your PATH
|
||||||
@@ -247,7 +260,7 @@ class JupyterHub(Application):
|
|||||||
token = orm.new_token()
|
token = orm.new_token()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
proxy_api_ip = Unicode('localhost', config=True,
|
proxy_api_ip = Unicode(localhost(), config=True,
|
||||||
help="The ip for the proxy API handlers"
|
help="The ip for the proxy API handlers"
|
||||||
)
|
)
|
||||||
proxy_api_port = Integer(config=True,
|
proxy_api_port = Integer(config=True,
|
||||||
@@ -259,7 +272,7 @@ class JupyterHub(Application):
|
|||||||
hub_port = Integer(8081, config=True,
|
hub_port = Integer(8081, config=True,
|
||||||
help="The port for this process"
|
help="The port for this process"
|
||||||
)
|
)
|
||||||
hub_ip = Unicode('localhost', config=True,
|
hub_ip = Unicode(localhost(), config=True,
|
||||||
help="The ip for this process"
|
help="The ip for this process"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,9 +348,13 @@ class JupyterHub(Application):
|
|||||||
debug_db = Bool(False, config=True,
|
debug_db = Bool(False, config=True,
|
||||||
help="log all database transactions. This has A LOT of output"
|
help="log all database transactions. This has A LOT of output"
|
||||||
)
|
)
|
||||||
db = Any()
|
|
||||||
session_factory = Any()
|
session_factory = Any()
|
||||||
|
|
||||||
|
users = Instance(UserDict)
|
||||||
|
def _users_default(self):
|
||||||
|
assert self.tornado_settings
|
||||||
|
return UserDict(db_factory=lambda : self.db, settings=self.tornado_settings)
|
||||||
|
|
||||||
admin_access = Bool(False, config=True,
|
admin_access = Bool(False, config=True,
|
||||||
help="""Grant admin users permission to access single-user servers.
|
help="""Grant admin users permission to access single-user servers.
|
||||||
|
|
||||||
@@ -345,11 +362,9 @@ class JupyterHub(Application):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
admin_users = Set(config=True,
|
admin_users = Set(config=True,
|
||||||
help="""set of usernames of admin users
|
help="""DEPRECATED, use Authenticator.admin_users instead."""
|
||||||
|
|
||||||
If unspecified, only the user that launches the server will be admin.
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tornado_settings = Dict(config=True)
|
tornado_settings = Dict(config=True)
|
||||||
|
|
||||||
cleanup_servers = Bool(True, config=True,
|
cleanup_servers = Bool(True, config=True,
|
||||||
@@ -455,13 +470,12 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
def init_handlers(self):
|
def init_handlers(self):
|
||||||
h = []
|
h = []
|
||||||
h.extend(handlers.default_handlers)
|
|
||||||
h.extend(apihandlers.default_handlers)
|
|
||||||
# load handlers from the authenticator
|
# load handlers from the authenticator
|
||||||
h.extend(self.authenticator.get_handlers(self))
|
h.extend(self.authenticator.get_handlers(self))
|
||||||
|
# set default handlers
|
||||||
|
h.extend(handlers.default_handlers)
|
||||||
|
h.extend(apihandlers.default_handlers)
|
||||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||||
|
|
||||||
# some extra handlers, outside hub_prefix
|
# some extra handlers, outside hub_prefix
|
||||||
self.handlers.extend([
|
self.handlers.extend([
|
||||||
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
|
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
|
||||||
@@ -531,6 +545,40 @@ class JupyterHub(Application):
|
|||||||
# store the loaded trait value
|
# store the loaded trait value
|
||||||
self.cookie_secret = secret
|
self.cookie_secret = secret
|
||||||
|
|
||||||
|
# thread-local storage of db objects
|
||||||
|
_local = Instance(threading.local, ())
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
if not hasattr(self._local, 'db'):
|
||||||
|
self._local.db = scoped_session(self.session_factory)()
|
||||||
|
return self._local.db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hub(self):
|
||||||
|
if not getattr(self._local, 'hub', None):
|
||||||
|
q = self.db.query(orm.Hub)
|
||||||
|
assert q.count() <= 1
|
||||||
|
self._local.hub = q.first()
|
||||||
|
return self._local.hub
|
||||||
|
|
||||||
|
@hub.setter
|
||||||
|
def hub(self, hub):
|
||||||
|
self._local.hub = hub
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy(self):
|
||||||
|
if not getattr(self._local, 'proxy', None):
|
||||||
|
q = self.db.query(orm.Proxy)
|
||||||
|
assert q.count() <= 1
|
||||||
|
p = self._local.proxy = q.first()
|
||||||
|
if p:
|
||||||
|
p.auth_token = self.proxy_auth_token
|
||||||
|
return self._local.proxy
|
||||||
|
|
||||||
|
@proxy.setter
|
||||||
|
def proxy(self, proxy):
|
||||||
|
self._local.proxy = proxy
|
||||||
|
|
||||||
def init_db(self):
|
def init_db(self):
|
||||||
"""Create the database connection"""
|
"""Create the database connection"""
|
||||||
self.log.debug("Connecting to db: %s", self.db_url)
|
self.log.debug("Connecting to db: %s", self.db_url)
|
||||||
@@ -541,7 +589,8 @@ class JupyterHub(Application):
|
|||||||
echo=self.debug_db,
|
echo=self.debug_db,
|
||||||
**self.db_kwargs
|
**self.db_kwargs
|
||||||
)
|
)
|
||||||
self.db = scoped_session(self.session_factory)()
|
# trigger constructing thread local db property
|
||||||
|
_ = self.db
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
self.log.error("Failed to connect to db: %s", self.db_url)
|
self.log.error("Failed to connect to db: %s", self.db_url)
|
||||||
self.log.debug("Database error was:", exc_info=True)
|
self.log.debug("Database error was:", exc_info=True)
|
||||||
@@ -574,16 +623,28 @@ class JupyterHub(Application):
|
|||||||
def init_users(self):
|
def init_users(self):
|
||||||
"""Load users into and from the database"""
|
"""Load users into and from the database"""
|
||||||
db = self.db
|
db = self.db
|
||||||
|
|
||||||
if not self.admin_users:
|
if self.admin_users and not self.authenticator.admin_users:
|
||||||
# add current user as admin if there aren't any others
|
self.log.warn(
|
||||||
admins = db.query(orm.User).filter(orm.User.admin==True)
|
"\nJupyterHub.admin_users is deprecated."
|
||||||
if admins.first() is None:
|
"\nUse Authenticator.admin_users instead."
|
||||||
self.admin_users.add(getuser())
|
)
|
||||||
|
self.authenticator.admin_users = self.admin_users
|
||||||
|
admin_users = [
|
||||||
|
self.authenticator.normalize_username(name)
|
||||||
|
for name in self.authenticator.admin_users
|
||||||
|
]
|
||||||
|
for username in admin_users:
|
||||||
|
if not self.authenticator.validate_username(username):
|
||||||
|
raise ValueError("username %r is not valid" % username)
|
||||||
|
|
||||||
|
if not admin_users:
|
||||||
|
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 = []
|
new_users = []
|
||||||
|
|
||||||
for name in self.admin_users:
|
for name in admin_users:
|
||||||
# ensure anyone specified as admin in config is admin in db
|
# ensure anyone specified as admin in config is admin in db
|
||||||
user = orm.User.find(db, name)
|
user = orm.User.find(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -596,7 +657,13 @@ class JupyterHub(Application):
|
|||||||
# the admin_users config variable will never be used after this point.
|
# the admin_users config variable will never be used after this point.
|
||||||
# only the database values will be referenced.
|
# only the database values will be referenced.
|
||||||
|
|
||||||
whitelist = self.authenticator.whitelist
|
whitelist = [
|
||||||
|
self.authenticator.normalize_username(name)
|
||||||
|
for name in self.authenticator.whitelist
|
||||||
|
]
|
||||||
|
for username in whitelist:
|
||||||
|
if not self.authenticator.validate_username(username):
|
||||||
|
raise ValueError("username %r is not valid" % username)
|
||||||
|
|
||||||
if not whitelist:
|
if not whitelist:
|
||||||
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
|
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
|
||||||
@@ -616,7 +683,7 @@ class JupyterHub(Application):
|
|||||||
# but changes to the whitelist can occur in the database,
|
# but changes to the whitelist can occur in the database,
|
||||||
# and persist across sessions.
|
# and persist across sessions.
|
||||||
for user in db.query(orm.User):
|
for user in db.query(orm.User):
|
||||||
whitelist.add(user.name)
|
self.authenticator.whitelist.add(user.name)
|
||||||
|
|
||||||
# The whitelist set and the users in the db are now the same.
|
# The whitelist set and the users in the db are now the same.
|
||||||
# From this point on, any user changes should be done simultaneously
|
# From this point on, any user changes should be done simultaneously
|
||||||
@@ -627,6 +694,10 @@ class JupyterHub(Application):
|
|||||||
for user in new_users:
|
for user in new_users:
|
||||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
@gen.coroutine
|
||||||
|
def init_spawners(self):
|
||||||
|
db = self.db
|
||||||
|
|
||||||
user_summaries = ['']
|
user_summaries = ['']
|
||||||
def _user_summary(user):
|
def _user_summary(user):
|
||||||
@@ -646,16 +717,15 @@ class JupyterHub(Application):
|
|||||||
yield self.proxy.delete_user(user)
|
yield self.proxy.delete_user(user)
|
||||||
yield user.stop()
|
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:
|
if not user.state:
|
||||||
# without spawner state, server isn't valid
|
# without spawner state, server isn't valid
|
||||||
user.server = None
|
user.server = None
|
||||||
user_summaries.append(_user_summary(user))
|
user_summaries.append(_user_summary(user))
|
||||||
continue
|
continue
|
||||||
self.log.debug("Loading state for %s from db", user.name)
|
self.log.debug("Loading state for %s from db", user.name)
|
||||||
user.spawner = spawner = self.spawner_class(
|
spawner = user.spawner
|
||||||
user=user, hub=self.hub, config=self.config, db=self.db,
|
|
||||||
)
|
|
||||||
status = yield spawner.poll()
|
status = yield spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
self.log.info("%s still running", user.name)
|
self.log.info("%s still running", user.name)
|
||||||
@@ -705,19 +775,19 @@ class JupyterHub(Application):
|
|||||||
if isinstance(e, HTTPError) and e.code == 403:
|
if isinstance(e, HTTPError) and e.code == 403:
|
||||||
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
||||||
else:
|
else:
|
||||||
msg = "Is something else using %s?" % self.proxy.public_server.url
|
msg = "Is something else using %s?" % self.proxy.public_server.bind_url
|
||||||
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
|
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
|
||||||
self.proxy.public_server.url, e, msg)
|
self.proxy.public_server.bind_url, e, msg)
|
||||||
self.exit(1)
|
self.exit(1)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.log.info("Proxy already running at: %s", self.proxy.public_server.url)
|
self.log.info("Proxy already running at: %s", self.proxy.public_server.bind_url)
|
||||||
self.proxy_process = None
|
self.proxy_process = None
|
||||||
return
|
return
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
||||||
cmd = [self.proxy_cmd,
|
cmd = self.proxy_cmd + [
|
||||||
'--ip', self.proxy.public_server.ip,
|
'--ip', self.proxy.public_server.ip,
|
||||||
'--port', str(self.proxy.public_server.port),
|
'--port', str(self.proxy.public_server.port),
|
||||||
'--api-ip', self.proxy.api_server.ip,
|
'--api-ip', self.proxy.api_server.ip,
|
||||||
@@ -730,9 +800,17 @@ class JupyterHub(Application):
|
|||||||
cmd.extend(['--ssl-key', self.ssl_key])
|
cmd.extend(['--ssl-key', self.ssl_key])
|
||||||
if self.ssl_cert:
|
if self.ssl_cert:
|
||||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||||
self.log.info("Starting proxy @ %s", self.proxy.public_server.url)
|
self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url)
|
||||||
self.log.debug("Proxy cmd: %s", cmd)
|
self.log.debug("Proxy cmd: %s", cmd)
|
||||||
self.proxy_process = Popen(cmd, env=env)
|
try:
|
||||||
|
self.proxy_process = Popen(cmd, env=env)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
self.log.error(
|
||||||
|
"Failed to find proxy %r\n"
|
||||||
|
"The proxy can be installed with `npm install -g configurable-http-proxy`"
|
||||||
|
% self.proxy_cmd
|
||||||
|
)
|
||||||
|
self.exit(1)
|
||||||
def _check():
|
def _check():
|
||||||
status = self.proxy_process.poll()
|
status = self.proxy_process.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -768,10 +846,13 @@ class JupyterHub(Application):
|
|||||||
def init_tornado_settings(self):
|
def init_tornado_settings(self):
|
||||||
"""Set up the tornado settings dict."""
|
"""Set up the tornado settings dict."""
|
||||||
base_url = self.hub.server.base_url
|
base_url = self.hub.server.base_url
|
||||||
template_path = os.path.join(self.data_files_path, 'templates'),
|
jinja_options = dict(
|
||||||
|
autoescape=True,
|
||||||
|
)
|
||||||
|
jinja_options.update(self.jinja_environment_options)
|
||||||
jinja_env = Environment(
|
jinja_env = Environment(
|
||||||
loader=FileSystemLoader(template_path),
|
loader=FileSystemLoader(self.template_paths),
|
||||||
**self.jinja_environment_options
|
**jinja_options
|
||||||
)
|
)
|
||||||
|
|
||||||
login_url = self.authenticator.login_url(base_url)
|
login_url = self.authenticator.login_url(base_url)
|
||||||
@@ -786,29 +867,33 @@ class JupyterHub(Application):
|
|||||||
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||||
|
|
||||||
settings = dict(
|
settings = dict(
|
||||||
|
log_function=log_request,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
log=self.log,
|
log=self.log,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
hub=self.hub,
|
hub=self.hub,
|
||||||
admin_users=self.admin_users,
|
admin_users=self.authenticator.admin_users,
|
||||||
admin_access=self.admin_access,
|
admin_access=self.admin_access,
|
||||||
authenticator=self.authenticator,
|
authenticator=self.authenticator,
|
||||||
spawner_class=self.spawner_class,
|
spawner_class=self.spawner_class,
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
cookie_secret=self.cookie_secret,
|
cookie_secret=self.cookie_secret,
|
||||||
|
cookie_max_age_days=self.cookie_max_age_days,
|
||||||
login_url=login_url,
|
login_url=login_url,
|
||||||
logout_url=logout_url,
|
logout_url=logout_url,
|
||||||
static_path=os.path.join(self.data_files_path, 'static'),
|
static_path=os.path.join(self.data_files_path, 'static'),
|
||||||
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'),
|
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'),
|
||||||
static_handler_class=CacheControlStaticFilesHandler,
|
static_handler_class=CacheControlStaticFilesHandler,
|
||||||
template_path=template_path,
|
template_path=self.template_paths,
|
||||||
jinja2_env=jinja_env,
|
jinja2_env=jinja_env,
|
||||||
version_hash=version_hash,
|
version_hash=version_hash,
|
||||||
)
|
)
|
||||||
# allow configured settings to have priority
|
# allow configured settings to have priority
|
||||||
settings.update(self.tornado_settings)
|
settings.update(self.tornado_settings)
|
||||||
self.tornado_settings = settings
|
self.tornado_settings = settings
|
||||||
|
# constructing users requires access to tornado_settings
|
||||||
|
self.tornado_settings['users'] = self.users
|
||||||
|
|
||||||
def init_tornado_application(self):
|
def init_tornado_application(self):
|
||||||
"""Instantiate the tornado Application object"""
|
"""Instantiate the tornado Application object"""
|
||||||
@@ -845,8 +930,9 @@ class JupyterHub(Application):
|
|||||||
self.init_hub()
|
self.init_hub()
|
||||||
self.init_proxy()
|
self.init_proxy()
|
||||||
yield self.init_users()
|
yield self.init_users()
|
||||||
self.init_handlers()
|
|
||||||
self.init_tornado_settings()
|
self.init_tornado_settings()
|
||||||
|
yield self.init_spawners()
|
||||||
|
self.init_handlers()
|
||||||
self.init_tornado_application()
|
self.init_tornado_application()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -857,7 +943,7 @@ class JupyterHub(Application):
|
|||||||
if self.cleanup_servers:
|
if self.cleanup_servers:
|
||||||
self.log.info("Cleaning up single-user servers...")
|
self.log.info("Cleaning up single-user servers...")
|
||||||
# request (async) process termination
|
# 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:
|
if user.spawner is not None:
|
||||||
futures.append(user.stop())
|
futures.append(user.stop())
|
||||||
else:
|
else:
|
||||||
@@ -955,6 +1041,16 @@ class JupyterHub(Application):
|
|||||||
loop.stop()
|
loop.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# start the webserver
|
||||||
|
self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True)
|
||||||
|
try:
|
||||||
|
self.http_server.listen(self.hub_port, address=self.hub_ip)
|
||||||
|
except Exception:
|
||||||
|
self.log.error("Failed to bind hub to %s", self.hub.server.bind_url)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.log.info("Hub API listening on %s", self.hub.server.bind_url)
|
||||||
|
|
||||||
# start the proxy
|
# start the proxy
|
||||||
try:
|
try:
|
||||||
yield self.start_proxy()
|
yield self.start_proxy()
|
||||||
@@ -976,12 +1072,12 @@ class JupyterHub(Application):
|
|||||||
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
# start the webserver
|
self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url)
|
||||||
self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True)
|
|
||||||
self.http_server.listen(self.hub_port)
|
|
||||||
|
|
||||||
# register cleanup on both TERM and INT
|
# register cleanup on both TERM and INT
|
||||||
atexit.register(self.atexit)
|
atexit.register(self.atexit)
|
||||||
|
self.init_signal()
|
||||||
|
|
||||||
|
def init_signal(self):
|
||||||
signal.signal(signal.SIGTERM, self.sigterm)
|
signal.signal(signal.SIGTERM, self.sigterm)
|
||||||
|
|
||||||
def sigterm(self, signum, frame):
|
def sigterm(self, signum, frame):
|
||||||
@@ -1006,7 +1102,10 @@ class JupyterHub(Application):
|
|||||||
if not self.io_loop:
|
if not self.io_loop:
|
||||||
return
|
return
|
||||||
if self.http_server:
|
if self.http_server:
|
||||||
self.io_loop.add_callback(self.http_server.stop)
|
if self.io_loop._running:
|
||||||
|
self.io_loop.add_callback(self.http_server.stop)
|
||||||
|
else:
|
||||||
|
self.http_server.stop()
|
||||||
self.io_loop.add_callback(self.io_loop.stop)
|
self.io_loop.add_callback(self.io_loop.stop)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -1020,7 +1119,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def launch_instance(cls, argv=None):
|
def launch_instance(cls, argv=None):
|
||||||
self = cls.instance(argv=argv)
|
self = cls.instance()
|
||||||
loop = IOLoop.current()
|
loop = IOLoop.current()
|
||||||
loop.add_callback(self.launch_instance_async, argv)
|
loop.add_callback(self.launch_instance_async, argv)
|
||||||
try:
|
try:
|
||||||
|
@@ -1,27 +1,40 @@
|
|||||||
"""Simple PAM authenticator"""
|
"""Base Authenticator class and the default PAM Authenticator"""
|
||||||
|
|
||||||
# Copyright (c) IPython Development Team.
|
# Copyright (c) IPython Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from grp import getgrnam
|
||||||
|
import pipes
|
||||||
import pwd
|
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
|
from tornado import gen
|
||||||
import simplepam
|
import pamela
|
||||||
|
|
||||||
from IPython.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from IPython.utils.traitlets import Bool, Set, Unicode, Any
|
from traitlets import Bool, Set, Unicode, Dict, Any
|
||||||
|
|
||||||
from .handlers.login import LoginHandler
|
from .handlers.login import LoginHandler
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
|
from .traitlets import Command
|
||||||
|
|
||||||
class Authenticator(LoggingConfigurable):
|
class Authenticator(LoggingConfigurable):
|
||||||
"""A class for authentication.
|
"""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()
|
db = Any()
|
||||||
|
admin_users = Set(config=True,
|
||||||
|
help="""set of usernames of admin users
|
||||||
|
|
||||||
|
If unspecified, only the user that launches the server will be admin.
|
||||||
|
"""
|
||||||
|
)
|
||||||
whitelist = Set(config=True,
|
whitelist = Set(config=True,
|
||||||
help="""Username whitelist.
|
help="""Username whitelist.
|
||||||
|
|
||||||
@@ -29,7 +42,100 @@ class Authenticator(LoggingConfigurable):
|
|||||||
If empty, allow any user to attempt login.
|
If empty, allow any user to attempt login.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
custom_html = Unicode('')
|
custom_html = Unicode('',
|
||||||
|
help="""HTML login form for custom handlers.
|
||||||
|
Override in form-based custom authenticators
|
||||||
|
that don't use username+password,
|
||||||
|
or need custom branding.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
login_service = Unicode('',
|
||||||
|
help="""Name of the login service for external
|
||||||
|
login services (e.g. 'GitHub').
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
username_pattern = Unicode(config=True,
|
||||||
|
help="""Regular expression pattern for validating usernames.
|
||||||
|
|
||||||
|
If not defined: allow any username.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
def _username_pattern_changed(self, name, old, new):
|
||||||
|
if not new:
|
||||||
|
self.username_regex = None
|
||||||
|
self.username_regex = re.compile(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(config=True,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
def authenticate(self, handler, data):
|
||||||
@@ -38,6 +144,28 @@ class Authenticator(LoggingConfigurable):
|
|||||||
This must be a tornado gen.coroutine.
|
This must be a tornado gen.coroutine.
|
||||||
It must return the username on successful authentication,
|
It must return the username on successful authentication,
|
||||||
and return None on failed 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 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
@@ -46,8 +174,14 @@ class Authenticator(LoggingConfigurable):
|
|||||||
By default, this just adds the user to the whitelist.
|
By default, this just adds the user to the whitelist.
|
||||||
|
|
||||||
Subclasses may do more extensive things,
|
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:
|
if self.whitelist:
|
||||||
self.whitelist.add(user.name)
|
self.whitelist.add(user.name)
|
||||||
|
|
||||||
@@ -55,30 +189,60 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""Triggered when a user is deleted.
|
"""Triggered when a user is deleted.
|
||||||
|
|
||||||
Removes the user from the whitelist.
|
Removes the user from the whitelist.
|
||||||
|
Subclasses should call super to ensure the whitelist is updated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): The User wrapper object
|
||||||
"""
|
"""
|
||||||
if user.name in self.whitelist:
|
self.whitelist.discard(user.name)
|
||||||
self.whitelist.remove(user.name)
|
|
||||||
|
|
||||||
def login_url(self, base_url):
|
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')
|
return url_path_join(base_url, 'login')
|
||||||
|
|
||||||
def logout_url(self, base_url):
|
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')
|
return url_path_join(base_url, 'logout')
|
||||||
|
|
||||||
def get_handlers(self, app):
|
def get_handlers(self, app):
|
||||||
"""Return any custom handlers the authenticator needs to register
|
"""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 [
|
return [
|
||||||
('/login', LoginHandler),
|
('/login', LoginHandler),
|
||||||
]
|
]
|
||||||
|
|
||||||
class LocalAuthenticator(Authenticator):
|
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.
|
Checks for local users, and can attempt to create them if they exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -87,15 +251,72 @@ class LocalAuthenticator(Authenticator):
|
|||||||
should I try to create the system user?
|
should I try to create the system user?
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
add_user_cmd = Command(config=True,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
if self.whitelist:
|
||||||
|
self.log.warn(
|
||||||
|
"Ignoring username whitelist because group whitelist supplied!"
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_whitelist(self, username):
|
||||||
|
if self.group_whitelist:
|
||||||
|
return self.check_group_whitelist(username)
|
||||||
|
else:
|
||||||
|
return super().check_whitelist(username)
|
||||||
|
|
||||||
|
def check_group_whitelist(self, username):
|
||||||
|
if not self.group_whitelist:
|
||||||
|
return False
|
||||||
|
for grnam in self.group_whitelist:
|
||||||
|
try:
|
||||||
|
group = getgrnam(grnam)
|
||||||
|
except KeyError:
|
||||||
|
self.log.error('No such group: [%s]' % grnam)
|
||||||
|
continue
|
||||||
|
if username in group.gr_mem:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
"""Add a new user
|
"""Add a new user
|
||||||
|
|
||||||
By default, this just adds the user to the whitelist.
|
If self.create_system_users, the user will attempt to be created.
|
||||||
|
|
||||||
Subclasses may do more extensive things,
|
|
||||||
such as adding actual unix users.
|
|
||||||
"""
|
"""
|
||||||
user_exists = yield gen.maybe_future(self.system_user_exists(user))
|
user_exists = yield gen.maybe_future(self.system_user_exists(user))
|
||||||
if not user_exists:
|
if not user_exists:
|
||||||
@@ -115,29 +336,21 @@ class LocalAuthenticator(Authenticator):
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
def add_system_user(self, user):
|
||||||
def add_system_user(user):
|
"""Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least."""
|
||||||
"""Create a new *ix user on the system. Works on FreeBSD and Linux, at least."""
|
|
||||||
name = user.name
|
name = user.name
|
||||||
for useradd in (
|
cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name]
|
||||||
['pw', 'useradd', '-m'],
|
self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
|
||||||
['useradd', '-m'],
|
p = Popen(cmd, stdout=PIPE, stderr=STDOUT)
|
||||||
):
|
p.wait()
|
||||||
try:
|
if p.returncode:
|
||||||
check_output(['which', useradd[0]])
|
err = p.stdout.read().decode('utf8', 'replace')
|
||||||
except CalledProcessError:
|
raise RuntimeError("Failed to create system user %s: %s" % (name, err))
|
||||||
continue
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise RuntimeError("I don't know how to add users on this system.")
|
|
||||||
|
|
||||||
check_call(useradd + [name])
|
|
||||||
|
|
||||||
|
|
||||||
class PAMAuthenticator(LocalAuthenticator):
|
class PAMAuthenticator(LocalAuthenticator):
|
||||||
"""Authenticate local *ix users with PAM"""
|
"""Authenticate local Linux/UNIX users with PAM"""
|
||||||
encoding = Unicode('utf8', config=True,
|
encoding = Unicode('utf8', config=True,
|
||||||
help="""The encoding to use for PAM"""
|
help="""The encoding to use for PAM"""
|
||||||
)
|
)
|
||||||
@@ -152,12 +365,27 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
Return None otherwise.
|
Return None otherwise.
|
||||||
"""
|
"""
|
||||||
username = data['username']
|
username = data['username']
|
||||||
if self.whitelist and username not in self.whitelist:
|
try:
|
||||||
return
|
pamela.authenticate(username, data['password'], service=self.service)
|
||||||
# simplepam wants bytes, not unicode
|
except pamela.PAMError as e:
|
||||||
# see simplepam#3
|
if handler is not None:
|
||||||
busername = username.encode(self.encoding)
|
self.log.warn("PAM Authentication failed (@%s): %s", handler.request.remote_ip, e)
|
||||||
bpassword = data['password'].encode(self.encoding)
|
else:
|
||||||
if simplepam.authenticate(busername, bpassword, service=self.service):
|
self.log.warn("PAM Authentication failed: %s", e)
|
||||||
|
else:
|
||||||
return username
|
return username
|
||||||
|
|
||||||
|
def pre_spawn_start(self, user, spawner):
|
||||||
|
"""Open PAM session for user"""
|
||||||
|
try:
|
||||||
|
pamela.open_session(user.name, service=self.service)
|
||||||
|
except pamela.PAMError as e:
|
||||||
|
self.log.warn("Failed to open PAM session for %s: %s", user.name, e)
|
||||||
|
|
||||||
|
def post_spawn_stop(self, user, spawner):
|
||||||
|
"""Close PAM session for user"""
|
||||||
|
try:
|
||||||
|
pamela.close_session(user.name, service=self.service)
|
||||||
|
except pamela.PAMError as e:
|
||||||
|
self.log.warn("Failed to close PAM session for %s: %s", user.name, e)
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
|
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
@@ -16,12 +16,20 @@ from tornado.web import RequestHandler
|
|||||||
from tornado import gen, web
|
from tornado import gen, web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..user import User
|
||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
# pattern for the authentication token header
|
# pattern for the authentication token header
|
||||||
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
|
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
|
||||||
|
|
||||||
|
# mapping of reason: reason_message
|
||||||
|
reasons = {
|
||||||
|
'timeout': "Failed to reach your server."
|
||||||
|
" Please try again later."
|
||||||
|
" Contact admin if the issue persists.",
|
||||||
|
'error': "Failed to start your server. Please contact admin.",
|
||||||
|
}
|
||||||
|
|
||||||
class BaseHandler(RequestHandler):
|
class BaseHandler(RequestHandler):
|
||||||
"""Base Handler class with access to common methods and properties."""
|
"""Base Handler class with access to common methods and properties."""
|
||||||
@@ -46,7 +54,11 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.settings['db']
|
return self.settings['db']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def users(self):
|
||||||
|
return self.settings.setdefault('users', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hub(self):
|
def hub(self):
|
||||||
return self.settings['hub']
|
return self.settings['hub']
|
||||||
@@ -62,7 +74,40 @@ class BaseHandler(RequestHandler):
|
|||||||
def finish(self, *args, **kwargs):
|
def finish(self, *args, **kwargs):
|
||||||
"""Roll back any uncommitted transactions from the handler."""
|
"""Roll back any uncommitted transactions from the handler."""
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
super(BaseHandler, self).finish(*args, **kwargs)
|
super().finish(*args, **kwargs)
|
||||||
|
|
||||||
|
#---------------------------------------------------------------
|
||||||
|
# Security policies
|
||||||
|
#---------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csp_report_uri(self):
|
||||||
|
return self.settings.get('csp_report_uri',
|
||||||
|
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_security_policy(self):
|
||||||
|
"""The default Content-Security-Policy header
|
||||||
|
|
||||||
|
Can be overridden by defining Content-Security-Policy in settings['headers']
|
||||||
|
"""
|
||||||
|
return '; '.join([
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"report-uri " + self.csp_report_uri,
|
||||||
|
])
|
||||||
|
|
||||||
|
def set_default_headers(self):
|
||||||
|
"""
|
||||||
|
Set any headers passed as tornado_settings['headers'].
|
||||||
|
|
||||||
|
By default sets Content-Security-Policy of frame-ancestors 'self'.
|
||||||
|
"""
|
||||||
|
headers = self.settings.get('headers', {})
|
||||||
|
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
||||||
|
|
||||||
|
for header_name, header_content in headers.items():
|
||||||
|
self.set_header(header_name, header_content)
|
||||||
|
|
||||||
#---------------------------------------------------------------
|
#---------------------------------------------------------------
|
||||||
# Login and cookie-related
|
# Login and cookie-related
|
||||||
@@ -71,6 +116,10 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def admin_users(self):
|
def admin_users(self):
|
||||||
return self.settings.setdefault('admin_users', set())
|
return self.settings.setdefault('admin_users', set())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookie_max_age_days(self):
|
||||||
|
return self.settings.get('cookie_max_age_days', None)
|
||||||
|
|
||||||
def get_current_user_token(self):
|
def get_current_user_token(self):
|
||||||
"""get_current_user from Authorization header token"""
|
"""get_current_user from Authorization header token"""
|
||||||
@@ -87,18 +136,34 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
"""Get the User for a given cookie, if there is one"""
|
"""Get the User for a given cookie, if there is one"""
|
||||||
cookie_id = self.get_secure_cookie(cookie_name, cookie_value)
|
cookie_id = self.get_secure_cookie(
|
||||||
|
cookie_name,
|
||||||
|
cookie_value,
|
||||||
|
max_age_days=self.cookie_max_age_days,
|
||||||
|
)
|
||||||
|
def clear():
|
||||||
|
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
||||||
|
|
||||||
if cookie_id is None:
|
if cookie_id is None:
|
||||||
|
if self.get_cookie(cookie_name):
|
||||||
|
self.log.warn("Invalid or expired cookie token")
|
||||||
|
clear()
|
||||||
return
|
return
|
||||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
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:
|
if user is None:
|
||||||
# don't log the token itself
|
|
||||||
self.log.warn("Invalid cookie token")
|
self.log.warn("Invalid cookie token")
|
||||||
# have cookie, but it's not valid. Clear it and start over.
|
# have cookie, but it's not valid. Clear it and start over.
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
clear()
|
||||||
return user
|
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):
|
def get_current_user_cookie(self):
|
||||||
"""get_current_user from a cookie token"""
|
"""get_current_user from a cookie token"""
|
||||||
return self._user_for_cookie(self.hub.server.cookie_name)
|
return self._user_for_cookie(self.hub.server.cookie_name)
|
||||||
@@ -115,37 +180,58 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
return None if no such user
|
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):
|
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)
|
user = self.find_user(username)
|
||||||
if user is None:
|
if user is None:
|
||||||
user = orm.User(name=username)
|
# not found, create and register user
|
||||||
self.db.add(user)
|
u = orm.User(name=username)
|
||||||
|
self.db.add(u)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
user = self._user_from_orm(u)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def clear_login_cookie(self):
|
def clear_login_cookie(self, name=None):
|
||||||
user = self.get_current_user()
|
if name is None:
|
||||||
|
user = self.get_current_user()
|
||||||
|
else:
|
||||||
|
user = self.find_user(name)
|
||||||
if user and user.server:
|
if user and user.server:
|
||||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||||
|
|
||||||
def set_server_cookie(self, user):
|
def set_server_cookie(self, user):
|
||||||
"""set the login cookie for the single-user server"""
|
"""set the login cookie for the single-user server"""
|
||||||
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
|
if self.request.protocol == 'https':
|
||||||
|
kwargs = {'secure':True}
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
user.server.cookie_name,
|
user.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
path=user.server.base_url,
|
path=user.server.base_url,
|
||||||
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_hub_cookie(self, user):
|
def set_hub_cookie(self, user):
|
||||||
"""set the login cookie for the Hub"""
|
"""set the login cookie for the Hub"""
|
||||||
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
|
if self.request.protocol == 'https':
|
||||||
|
kwargs = {'secure':True}
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
self.hub.server.cookie_name,
|
self.hub.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
path=self.hub.server.base_url)
|
path=self.hub.server.base_url,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
def set_login_cookie(self, user):
|
def set_login_cookie(self, user):
|
||||||
"""Set login cookies for the Hub and single-user server."""
|
"""Set login cookies for the Hub and single-user server."""
|
||||||
@@ -161,7 +247,7 @@ class BaseHandler(RequestHandler):
|
|||||||
def authenticate(self, data):
|
def authenticate(self, data):
|
||||||
auth = self.authenticator
|
auth = self.authenticator
|
||||||
if auth is not None:
|
if auth is not None:
|
||||||
result = yield auth.authenticate(self, data)
|
result = yield auth.get_authenticated_user(self, data)
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
self.log.error("No authentication function, login is impossible!")
|
self.log.error("No authentication function, login is impossible!")
|
||||||
@@ -182,19 +268,15 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def spawner_class(self):
|
def spawner_class(self):
|
||||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def spawn_single_user(self, user):
|
def spawn_single_user(self, user, options=None):
|
||||||
if user.spawn_pending:
|
if user.spawn_pending:
|
||||||
raise RuntimeError("Spawn already pending for: %s" % user.name)
|
raise RuntimeError("Spawn already pending for: %s" % user.name)
|
||||||
tic = IOLoop.current().time()
|
tic = IOLoop.current().time()
|
||||||
|
|
||||||
f = user.spawn(
|
f = user.spawn(options)
|
||||||
spawner_class=self.spawner_class,
|
|
||||||
base_url=self.base_url,
|
|
||||||
hub=self.hub,
|
|
||||||
config=self.config,
|
|
||||||
)
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def finish_user_spawn(f=None):
|
def finish_user_spawn(f=None):
|
||||||
"""Finish the user spawn by registering listeners and notifying the proxy.
|
"""Finish the user spawn by registering listeners and notifying the proxy.
|
||||||
@@ -214,10 +296,14 @@ class BaseHandler(RequestHandler):
|
|||||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
if user.spawn_pending:
|
if user.spawn_pending:
|
||||||
# hit timeout, but spawn is still pending
|
status = yield user.spawner.poll()
|
||||||
self.log.warn("User %s server is slow to start", user.name)
|
if status is None:
|
||||||
# schedule finish for when the user finishes spawning
|
# hit timeout, but spawn is still pending
|
||||||
IOLoop.current().add_future(f, finish_user_spawn)
|
self.log.warn("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 web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
@@ -289,6 +375,7 @@ class BaseHandler(RequestHandler):
|
|||||||
prefix=self.base_url,
|
prefix=self.base_url,
|
||||||
user=user,
|
user=user,
|
||||||
login_url=self.settings['login_url'],
|
login_url=self.settings['login_url'],
|
||||||
|
login_service=self.authenticator.login_service,
|
||||||
logout_url=self.settings['logout_url'],
|
logout_url=self.settings['logout_url'],
|
||||||
static_url=self.static_url,
|
static_url=self.static_url,
|
||||||
version_hash=self.version_hash,
|
version_hash=self.version_hash,
|
||||||
@@ -310,7 +397,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# construct the custom reason, if defined
|
# construct the custom reason, if defined
|
||||||
reason = getattr(exception, 'reason', '')
|
reason = getattr(exception, 'reason', '')
|
||||||
if reason:
|
if reason:
|
||||||
status_message = reason
|
message = reasons.get(reason, reason)
|
||||||
|
|
||||||
# build template namespace
|
# build template namespace
|
||||||
ns = dict(
|
ns = dict(
|
||||||
@@ -343,11 +430,12 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
Redirects /foo to /prefix/foo, etc.
|
Redirects /foo to /prefix/foo, etc.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
path = self.request.path[len(self.base_url):]
|
path = self.request.uri[len(self.base_url):]
|
||||||
self.redirect(url_path_join(
|
self.redirect(url_path_join(
|
||||||
self.hub.server.base_url, path,
|
self.hub.server.base_url, path,
|
||||||
), permanent=False)
|
), permanent=False)
|
||||||
|
|
||||||
|
|
||||||
class UserSpawnHandler(BaseHandler):
|
class UserSpawnHandler(BaseHandler):
|
||||||
"""Requests to /user/name handled by the Hub
|
"""Requests to /user/name handled by the Hub
|
||||||
should result in spawning the single-user server and
|
should result in spawning the single-user server and
|
||||||
@@ -368,12 +456,14 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
# spawn has supposedly finished, check on the status
|
# spawn has supposedly finished, check on the status
|
||||||
status = yield current_user.spawner.poll()
|
status = yield current_user.spawner.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
yield self.spawn_single_user(current_user)
|
if current_user.spawner.options_form:
|
||||||
else:
|
self.redirect(url_path_join(self.hub.server.base_url, 'spawn'))
|
||||||
yield self.spawn_single_user(current_user)
|
return
|
||||||
|
else:
|
||||||
|
yield self.spawn_single_user(current_user)
|
||||||
# set login cookie anew
|
# set login cookie anew
|
||||||
self.set_login_cookie(current_user)
|
self.set_login_cookie(current_user)
|
||||||
without_prefix = self.request.path[len(self.hub.server.base_url):]
|
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||||
target = url_path_join(self.base_url, without_prefix)
|
target = url_path_join(self.base_url, without_prefix)
|
||||||
self.redirect(target)
|
self.redirect(target)
|
||||||
else:
|
else:
|
||||||
@@ -382,9 +472,18 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
self.redirect(url_concat(
|
self.redirect(url_concat(
|
||||||
self.settings['login_url'],
|
self.settings['login_url'],
|
||||||
{'next': self.request.path,
|
{'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'))
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||||
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
]
|
]
|
||||||
|
@@ -12,31 +12,44 @@ from .base import BaseHandler
|
|||||||
class LogoutHandler(BaseHandler):
|
class LogoutHandler(BaseHandler):
|
||||||
"""Log a user out by clearing their login cookie."""
|
"""Log a user out by clearing their login cookie."""
|
||||||
def get(self):
|
def get(self):
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user:
|
||||||
|
self.log.info("User logged out: %s", user.name)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
html = self.render_template('logout.html')
|
for name in user.other_user_cookies:
|
||||||
self.finish(html)
|
self.clear_login_cookie(name)
|
||||||
|
user.other_user_cookies = set([])
|
||||||
|
self.redirect(self.hub.server.base_url, permanent=False)
|
||||||
|
|
||||||
|
|
||||||
class LoginHandler(BaseHandler):
|
class LoginHandler(BaseHandler):
|
||||||
"""Render the login page."""
|
"""Render the login page."""
|
||||||
|
|
||||||
def _render(self, message=None, username=None):
|
def _render(self, login_error=None, username=None):
|
||||||
return self.render_template('login.html',
|
return self.render_template('login.html',
|
||||||
next=url_escape(self.get_argument('next', default='')),
|
next=url_escape(self.get_argument('next', default='')),
|
||||||
username=username,
|
username=username,
|
||||||
message=message,
|
login_error=login_error,
|
||||||
custom_html=self.authenticator.custom_html,
|
custom_html=self.authenticator.custom_html,
|
||||||
|
login_url=self.settings['login_url'],
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
next_url = self.get_argument('next', False)
|
next_url = self.get_argument('next', '')
|
||||||
if next_url and self.get_current_user():
|
if not next_url.startswith('/'):
|
||||||
|
# disallow non-absolute next URLs (e.g. full URLs)
|
||||||
|
next_url = ''
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user:
|
||||||
|
if not next_url:
|
||||||
|
if user.running:
|
||||||
|
next_url = user.server.base_url
|
||||||
|
else:
|
||||||
|
next_url = self.hub.server.base_url
|
||||||
# set new login cookie
|
# set new login cookie
|
||||||
# because single-user cookie may have been cleared or incorrect
|
# because single-user cookie may have been cleared or incorrect
|
||||||
self.set_login_cookie(self.get_current_user())
|
self.set_login_cookie(self.get_current_user())
|
||||||
self.redirect(next_url, permanent=False)
|
self.redirect(next_url, permanent=False)
|
||||||
elif not next_url and self.get_current_user():
|
|
||||||
self.redirect(self.hub.server.base_url, permanent=False)
|
|
||||||
else:
|
else:
|
||||||
username = self.get_argument('username', default='')
|
username = self.get_argument('username', default='')
|
||||||
self.finish(self._render(username=username))
|
self.finish(self._render(username=username))
|
||||||
@@ -48,23 +61,26 @@ class LoginHandler(BaseHandler):
|
|||||||
for arg in self.request.arguments:
|
for arg in self.request.arguments:
|
||||||
data[arg] = self.get_argument(arg)
|
data[arg] = self.get_argument(arg)
|
||||||
|
|
||||||
username = data['username']
|
username = yield self.authenticate(data)
|
||||||
authorized = yield self.authenticate(data)
|
if username:
|
||||||
if authorized:
|
|
||||||
user = self.user_from_username(username)
|
user = self.user_from_username(username)
|
||||||
already_running = False
|
already_running = False
|
||||||
if user.spawner:
|
if user.spawner:
|
||||||
status = yield user.spawner.poll()
|
status = yield user.spawner.poll()
|
||||||
already_running = (status == None)
|
already_running = (status == None)
|
||||||
if not already_running:
|
if not already_running and not user.spawner.options_form:
|
||||||
yield self.spawn_single_user(user)
|
yield self.spawn_single_user(user)
|
||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
next_url = self.get_argument('next', default='') or self.hub.server.base_url
|
next_url = self.get_argument('next', default='')
|
||||||
|
if not next_url.startswith('/'):
|
||||||
|
next_url = ''
|
||||||
|
next_url = next_url or self.hub.server.base_url
|
||||||
self.redirect(next_url)
|
self.redirect(next_url)
|
||||||
|
self.log.info("User logged in: %s", username)
|
||||||
else:
|
else:
|
||||||
self.log.debug("Failed login for %s", username)
|
self.log.debug("Failed login for %s", username)
|
||||||
html = self._render(
|
html = self._render(
|
||||||
message={'error': 'Invalid username or password'},
|
login_error='Invalid username or password',
|
||||||
username=username,
|
username=username,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
@@ -3,31 +3,38 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web, gen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only, url_path_join
|
from ..utils import admin_only, url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
|
from .login import LoginHandler
|
||||||
|
|
||||||
|
|
||||||
class RootHandler(BaseHandler):
|
class RootHandler(BaseHandler):
|
||||||
"""Render the Hub root page.
|
"""Render the Hub root page.
|
||||||
|
|
||||||
Currently redirects to home if logged in,
|
If logged in, redirects to:
|
||||||
shows big fat login button otherwise.
|
|
||||||
|
- single-user server if running
|
||||||
|
- hub home, otherwise
|
||||||
|
|
||||||
|
Otherwise, renders login page.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
if self.get_current_user():
|
user = self.get_current_user()
|
||||||
self.redirect(
|
if user:
|
||||||
url_path_join(self.hub.server.base_url, 'home'),
|
if user.running:
|
||||||
permanent=False,
|
url = user.server.base_url
|
||||||
)
|
self.log.debug("User is running: %s", url)
|
||||||
|
else:
|
||||||
|
url = url_path_join(self.hub.server.base_url, 'home')
|
||||||
|
self.log.debug("User is not running: %s", url)
|
||||||
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
|
url = url_path_join(self.hub.server.base_url, 'login')
|
||||||
html = self.render_template('index.html',
|
self.redirect(url)
|
||||||
login_url=self.settings['login_url'],
|
|
||||||
)
|
|
||||||
self.finish(html)
|
|
||||||
|
|
||||||
class HomeHandler(BaseHandler):
|
class HomeHandler(BaseHandler):
|
||||||
"""Render the user's home page."""
|
"""Render the user's home page."""
|
||||||
@@ -40,6 +47,63 @@ class HomeHandler(BaseHandler):
|
|||||||
self.finish(html)
|
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.server.base_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.
|
||||||
|
url = url_path_join(self.base_url, 'user', user.name)
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
@gen.coroutine
|
||||||
|
def post(self):
|
||||||
|
"""POST spawns with user-specified options"""
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user.running:
|
||||||
|
url = user.server.base_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
|
||||||
|
options = user.spawner.options_from_form(form_options)
|
||||||
|
try:
|
||||||
|
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.server.base_url
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
class AdminHandler(BaseHandler):
|
class AdminHandler(BaseHandler):
|
||||||
"""Render the admin page."""
|
"""Render the admin page."""
|
||||||
|
|
||||||
@@ -83,7 +147,8 @@ class AdminHandler(BaseHandler):
|
|||||||
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
||||||
|
|
||||||
users = self.db.query(orm.User).order_by(*ordered)
|
users = self.db.query(orm.User).order_by(*ordered)
|
||||||
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',
|
html = self.render_template('admin.html',
|
||||||
user=self.get_current_user(),
|
user=self.get_current_user(),
|
||||||
@@ -99,4 +164,5 @@ default_handlers = [
|
|||||||
(r'/', RootHandler),
|
(r'/', RootHandler),
|
||||||
(r'/home', HomeHandler),
|
(r'/home', HomeHandler),
|
||||||
(r'/admin', AdminHandler),
|
(r'/admin', AdminHandler),
|
||||||
|
(r'/spawn', SpawnHandler),
|
||||||
]
|
]
|
||||||
|
@@ -2,9 +2,12 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from tornado.log import LogFormatter
|
from tornado.log import LogFormatter, access_log
|
||||||
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
|
|
||||||
def coroutine_traceback(typ, value, tb):
|
def coroutine_traceback(typ, value, tb):
|
||||||
"""Scrub coroutine frames from a traceback
|
"""Scrub coroutine frames from a traceback
|
||||||
@@ -38,3 +41,61 @@ class CoroutineLogFormatter(LogFormatter):
|
|||||||
def formatException(self, exc_info):
|
def formatException(self, exc_info):
|
||||||
return ''.join(coroutine_traceback(*exc_info))
|
return ''.join(coroutine_traceback(*exc_info))
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_uri(uri):
|
||||||
|
"""scrub auth info from uri"""
|
||||||
|
if '/api/authorizations/cookie/' in uri or '/api/authorizations/token/' in uri:
|
||||||
|
uri = uri.rsplit('/', 1)[0] + '/[secret]'
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_headers(headers):
|
||||||
|
"""scrub auth info from headers"""
|
||||||
|
headers = dict(headers)
|
||||||
|
if 'Authorization' in headers:
|
||||||
|
auth = headers['Authorization']
|
||||||
|
if auth.startswith('token '):
|
||||||
|
headers['Authorization'] = 'token [secret]'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
# log_request adapted from IPython (BSD)
|
||||||
|
|
||||||
|
def log_request(handler):
|
||||||
|
"""log a bit more information about each request than tornado's default
|
||||||
|
|
||||||
|
- move static file get success to debug-level (reduces noise)
|
||||||
|
- get proxied IP instead of proxy IP
|
||||||
|
- log referer for redirect and failed requests
|
||||||
|
- log user-agent for failed requests
|
||||||
|
"""
|
||||||
|
status = handler.get_status()
|
||||||
|
request = handler.request
|
||||||
|
if status == 304 or (status < 300 and isinstance(handler, StaticFileHandler)):
|
||||||
|
# static-file success and 304 Found are debug-level
|
||||||
|
log_method = access_log.debug
|
||||||
|
elif status < 400:
|
||||||
|
log_method = access_log.info
|
||||||
|
elif status < 500:
|
||||||
|
log_method = access_log.warning
|
||||||
|
else:
|
||||||
|
log_method = access_log.error
|
||||||
|
|
||||||
|
uri = _scrub_uri(request.uri)
|
||||||
|
headers = _scrub_headers(request.headers)
|
||||||
|
|
||||||
|
request_time = 1000.0 * handler.request.request_time()
|
||||||
|
user = handler.get_current_user()
|
||||||
|
ns = dict(
|
||||||
|
status=status,
|
||||||
|
method=request.method,
|
||||||
|
ip=request.remote_ip,
|
||||||
|
uri=uri,
|
||||||
|
request_time=request_time,
|
||||||
|
user=user.name if user else ''
|
||||||
|
)
|
||||||
|
msg = "{status} {method} {uri} ({user}@{ip}) {request_time:.2f}ms"
|
||||||
|
if status >= 500 and status != 502:
|
||||||
|
log_method(json.dumps(headers, indent=2))
|
||||||
|
log_method(msg.format(**ns))
|
||||||
|
|
||||||
|
@@ -3,14 +3,14 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError
|
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||||
|
|
||||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -26,7 +26,7 @@ from sqlalchemy import create_engine
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||||
new_token, hash_token, compare_token,
|
new_token, hash_token, compare_token, localhost,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,12 +75,16 @@ class Server(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
|
ip = self.ip
|
||||||
|
if ip in {'', '0.0.0.0'}:
|
||||||
|
# when listening on all interfaces, connect to localhost
|
||||||
|
ip = localhost()
|
||||||
return "{proto}://{ip}:{port}".format(
|
return "{proto}://{ip}:{port}".format(
|
||||||
proto=self.proto,
|
proto=self.proto,
|
||||||
ip=self.ip or 'localhost',
|
ip=ip,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self):
|
def url(self):
|
||||||
return "{host}{uri}".format(
|
return "{host}{uri}".format(
|
||||||
@@ -88,20 +92,41 @@ class Server(Base):
|
|||||||
uri=self.base_url,
|
uri=self.base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bind_url(self):
|
||||||
|
"""representation of URL used for binding
|
||||||
|
|
||||||
|
Never used in APIs, only logging,
|
||||||
|
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
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_up(self, timeout=10, http=False):
|
def wait_up(self, timeout=10, http=False):
|
||||||
"""Wait for this server to come up"""
|
"""Wait for this server to come up"""
|
||||||
if http:
|
if http:
|
||||||
yield wait_for_http_server(self.url, timeout=timeout)
|
yield wait_for_http_server(self.url, timeout=timeout)
|
||||||
else:
|
else:
|
||||||
yield wait_for_server(self.ip or 'localhost', self.port, timeout=timeout)
|
yield wait_for_server(self.ip or localhost(), self.port, timeout=timeout)
|
||||||
|
|
||||||
def is_up(self):
|
def is_up(self):
|
||||||
"""Is the server accepting connections?"""
|
"""Is the server accepting connections?"""
|
||||||
try:
|
try:
|
||||||
socket.create_connection((self.ip or 'localhost', self.port))
|
socket.create_connection((self.ip or localhost(), self.port))
|
||||||
except socket.error as e:
|
except socket.error as e:
|
||||||
if e.errno == errno.ECONNREFUSED:
|
if e.errno == errno.ENETUNREACH:
|
||||||
|
try:
|
||||||
|
socket.create_connection((self.ip or '127.0.0.1', self.port))
|
||||||
|
except socket.error as e:
|
||||||
|
if e.errno == errno.ECONNREFUSED:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
elif e.errno == errno.ECONNREFUSED:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
@@ -130,7 +155,7 @@ class Proxy(Base):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||||
|
|
||||||
def api_request(self, path, method='GET', body=None, client=None):
|
def api_request(self, path, method='GET', body=None, client=None):
|
||||||
"""Make an authenticated API request of the proxy"""
|
"""Make an authenticated API request of the proxy"""
|
||||||
client = client or AsyncHTTPClient()
|
client = client or AsyncHTTPClient()
|
||||||
@@ -255,7 +280,7 @@ class User(Base):
|
|||||||
used for restoring state of a Spawner.
|
used for restoring state of a Spawner.
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(Unicode)
|
name = Column(Unicode)
|
||||||
# should we allow multiple servers per user?
|
# should we allow multiple servers per user?
|
||||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
@@ -266,10 +291,9 @@ class User(Base):
|
|||||||
api_tokens = relationship("APIToken", backref="user")
|
api_tokens = relationship("APIToken", backref="user")
|
||||||
cookie_id = Column(Unicode, default=new_token)
|
cookie_id = Column(Unicode, default=new_token)
|
||||||
state = Column(JSONDict)
|
state = Column(JSONDict)
|
||||||
spawner = None
|
|
||||||
spawn_pending = False
|
other_user_cookies = set([])
|
||||||
stop_pending = False
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.server:
|
if self.server:
|
||||||
return "<{cls}({name}@{ip}:{port})>".format(
|
return "<{cls}({name}@{ip}:{port})>".format(
|
||||||
@@ -284,15 +308,6 @@ class User(Base):
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@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):
|
def new_api_token(self):
|
||||||
"""Create a new API token"""
|
"""Create a new API token"""
|
||||||
assert self.id is not None
|
assert self.id is not None
|
||||||
@@ -303,7 +318,7 @@ class User(Base):
|
|||||||
db.add(orm_token)
|
db.add(orm_token)
|
||||||
db.commit()
|
db.commit()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, name):
|
def find(cls, db, name):
|
||||||
"""Find a user by name.
|
"""Find a user by name.
|
||||||
@@ -311,111 +326,6 @@ class User(Base):
|
|||||||
Returns None if not found.
|
Returns None if not found.
|
||||||
"""
|
"""
|
||||||
return db.query(cls).filter(cls.name==name).first()
|
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, self.name),
|
|
||||||
base_url=url_path_join(base_url, 'user', self.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,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
|
||||||
user=self.name, 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 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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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.last_activity = datetime.utcnow()
|
|
||||||
self.server = None
|
|
||||||
inspect(self).session.commit()
|
|
||||||
finally:
|
|
||||||
self.stop_pending = False
|
|
||||||
|
|
||||||
|
|
||||||
class APIToken(Base):
|
class APIToken(Base):
|
||||||
"""An API token"""
|
"""An API token"""
|
||||||
|
@@ -1,156 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""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
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from tornado import ioloop
|
|
||||||
from tornado.web import HTTPError
|
|
||||||
|
|
||||||
from IPython.utils.traitlets import Unicode
|
|
||||||
|
|
||||||
from IPython.html.notebookapp import NotebookApp
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
headers = {'Authorization' : 'token %s' % hub_api_key},
|
|
||||||
data=encrypted_cookie,
|
|
||||||
)
|
|
||||||
if r.status_code == 404:
|
|
||||||
data = {'user' : ''}
|
|
||||||
if 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['user']
|
|
||||||
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 = NotebookApp.aliases.get_default_value()
|
|
||||||
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',
|
|
||||||
})
|
|
||||||
|
|
||||||
class SingleUserNotebookApp(NotebookApp):
|
|
||||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
|
||||||
user = Unicode(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
|
|
||||||
login_handler_class = JupyterHubLoginHandler
|
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
|
||||||
|
|
||||||
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 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'] = url_path_join(self.hub_prefix, 'login')
|
|
||||||
s['hub_api_url'] = self.hub_api_url
|
|
||||||
super(SingleUserNotebookApp, self).init_webapp()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
return SingleUserNotebookApp.launch_instance()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
@@ -7,23 +7,22 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import pipes
|
import pipes
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from subprocess import Popen, check_output, PIPE, CalledProcessError
|
import grp
|
||||||
|
from subprocess import Popen
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||||
|
|
||||||
from IPython.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from IPython.utils.traitlets import (
|
from traitlets import (
|
||||||
Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode,
|
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .utils import random_port
|
from .traitlets import Command
|
||||||
|
from .utils import random_port, localhost
|
||||||
NUM_PAT = re.compile(r'\d+')
|
|
||||||
|
|
||||||
class Spawner(LoggingConfigurable):
|
class Spawner(LoggingConfigurable):
|
||||||
"""Base class for spawning single-user notebook servers.
|
"""Base class for spawning single-user notebook servers.
|
||||||
@@ -40,8 +39,9 @@ class Spawner(LoggingConfigurable):
|
|||||||
db = Any()
|
db = Any()
|
||||||
user = Any()
|
user = Any()
|
||||||
hub = Any()
|
hub = Any()
|
||||||
|
authenticator = Any()
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
ip = Unicode('localhost', config=True,
|
ip = Unicode(localhost(), config=True,
|
||||||
help="The IP address (or hostname) the single-user server should listen on"
|
help="The IP address (or hostname) the single-user server should listen on"
|
||||||
)
|
)
|
||||||
start_timeout = Integer(60, config=True,
|
start_timeout = Integer(60, config=True,
|
||||||
@@ -54,7 +54,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
http_timeout = Integer(
|
http_timeout = Integer(
|
||||||
10, config=True,
|
30, config=True,
|
||||||
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
||||||
|
|
||||||
Once a server has successfully been spawned, this is the amount of time
|
Once a server has successfully been spawned, this is the amount of time
|
||||||
@@ -73,6 +73,38 @@ class Spawner(LoggingConfigurable):
|
|||||||
help="Enable debug-logging of the single-user server"
|
help="Enable debug-logging of the single-user server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
options_form = Unicode("", config=True, 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>
|
||||||
|
""")
|
||||||
|
|
||||||
|
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([
|
env_keep = List([
|
||||||
'PATH',
|
'PATH',
|
||||||
'PYTHONPATH',
|
'PYTHONPATH',
|
||||||
@@ -93,7 +125,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
env['JPY_API_TOKEN'] = self.api_token
|
env['JPY_API_TOKEN'] = self.api_token
|
||||||
return env
|
return env
|
||||||
|
|
||||||
cmd = List(Unicode, default_value=['jupyterhub-singleuser'], config=True,
|
cmd = Command(['jupyterhub-singleuser'], config=True,
|
||||||
help="""The command used for starting notebooks."""
|
help="""The command used for starting notebooks."""
|
||||||
)
|
)
|
||||||
args = List(Unicode, config=True,
|
args = List(Unicode, config=True,
|
||||||
@@ -104,6 +136,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
help="""The notebook directory for the single-user server
|
help="""The notebook directory for the single-user server
|
||||||
|
|
||||||
`~` will be expanded to the user's home directory
|
`~` will be expanded to the user's home directory
|
||||||
|
`%U` will be expanded to the user's username
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,7 +164,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""store the state necessary for load_state
|
"""store the state necessary for load_state
|
||||||
|
|
||||||
A black box of extra state for custom spawners.
|
A black box of extra state for custom spawners.
|
||||||
Should call `super`.
|
Subclasses should call `super`.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -151,6 +184,14 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
self.api_token = ''
|
self.api_token = ''
|
||||||
|
|
||||||
|
def get_env(self):
|
||||||
|
"""Return the environment we should use
|
||||||
|
|
||||||
|
Default returns a copy of self.env.
|
||||||
|
Use this to access the env in Spawner.start to allow extension in subclasses.
|
||||||
|
"""
|
||||||
|
return self.env.copy()
|
||||||
|
|
||||||
def get_args(self):
|
def get_args(self):
|
||||||
"""Return the arguments to be passed after self.cmd"""
|
"""Return the arguments to be passed after self.cmd"""
|
||||||
args = [
|
args = [
|
||||||
@@ -164,6 +205,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
if self.ip:
|
if self.ip:
|
||||||
args.append('--ip=%s' % self.ip)
|
args.append('--ip=%s' % self.ip)
|
||||||
if self.notebook_dir:
|
if self.notebook_dir:
|
||||||
|
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
|
||||||
args.append('--notebook-dir=%s' % self.notebook_dir)
|
args.append('--notebook-dir=%s' % self.notebook_dir)
|
||||||
if self.debug:
|
if self.debug:
|
||||||
args.append('--debug')
|
args.append('--debug')
|
||||||
@@ -252,7 +294,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + self.death_interval)
|
yield gen.sleep(self.death_interval)
|
||||||
|
|
||||||
def _try_setcwd(path):
|
def _try_setcwd(path):
|
||||||
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
||||||
@@ -275,13 +317,15 @@ def set_user_setuid(username):
|
|||||||
uid = user.pw_uid
|
uid = user.pw_uid
|
||||||
gid = user.pw_gid
|
gid = user.pw_gid
|
||||||
home = user.pw_dir
|
home = user.pw_dir
|
||||||
|
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
||||||
|
|
||||||
def preexec():
|
def preexec():
|
||||||
# don't forward signals
|
|
||||||
os.setpgrp()
|
|
||||||
|
|
||||||
# set the user and group
|
# set the user and group
|
||||||
os.setgid(gid)
|
os.setgid(gid)
|
||||||
|
try:
|
||||||
|
os.setgroups(gids)
|
||||||
|
except Exception as e:
|
||||||
|
print('Failed to set groups %s' % e, file=sys.stderr)
|
||||||
os.setuid(uid)
|
os.setuid(uid)
|
||||||
|
|
||||||
# start in the user's home dir
|
# start in the user's home dir
|
||||||
@@ -291,7 +335,12 @@ def set_user_setuid(username):
|
|||||||
|
|
||||||
|
|
||||||
class LocalProcessSpawner(Spawner):
|
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.
|
||||||
|
|
||||||
|
Requires users to exist on the local system.
|
||||||
|
|
||||||
|
This is the default spawner for JupyterHub.
|
||||||
|
"""
|
||||||
|
|
||||||
INTERRUPT_TIMEOUT = Integer(10, config=True,
|
INTERRUPT_TIMEOUT = Integer(10, config=True,
|
||||||
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
|
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
|
||||||
@@ -303,7 +352,7 @@ class LocalProcessSpawner(Spawner):
|
|||||||
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
||||||
)
|
)
|
||||||
|
|
||||||
proc = Instance(Popen)
|
proc = Instance(Popen, allow_none=True)
|
||||||
pid = Integer(0)
|
pid = Integer(0)
|
||||||
|
|
||||||
def make_preexec_fn(self, name):
|
def make_preexec_fn(self, name):
|
||||||
@@ -329,12 +378,21 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
env['USER'] = self.user.name
|
env['USER'] = self.user.name
|
||||||
env['HOME'] = pwd.getpwnam(self.user.name).pw_dir
|
home = pwd.getpwnam(self.user.name).pw_dir
|
||||||
|
shell = pwd.getpwnam(self.user.name).pw_shell
|
||||||
|
# These will be empty if undefined,
|
||||||
|
# in which case don't set the env:
|
||||||
|
if home:
|
||||||
|
env['HOME'] = home
|
||||||
|
if shell:
|
||||||
|
env['SHELL'] = shell
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _env_default(self):
|
def get_env(self):
|
||||||
env = super()._env_default()
|
"""Add user environment variables"""
|
||||||
return self.user_env(env)
|
env = super().get_env()
|
||||||
|
env = self.user_env(env)
|
||||||
|
return env
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -343,7 +401,7 @@ class LocalProcessSpawner(Spawner):
|
|||||||
self.user.server.ip = self.ip
|
self.user.server.ip = self.ip
|
||||||
self.user.server.port = random_port()
|
self.user.server.port = random_port()
|
||||||
cmd = []
|
cmd = []
|
||||||
env = self.env.copy()
|
env = self.get_env()
|
||||||
|
|
||||||
cmd.extend(self.cmd)
|
cmd.extend(self.cmd)
|
||||||
cmd.extend(self.get_args())
|
cmd.extend(self.get_args())
|
||||||
@@ -351,6 +409,7 @@ class LocalProcessSpawner(Spawner):
|
|||||||
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
|
||||||
self.proc = Popen(cmd, env=env,
|
self.proc = Popen(cmd, env=env,
|
||||||
preexec_fn=self.make_preexec_fn(self.user.name),
|
preexec_fn=self.make_preexec_fn(self.user.name),
|
||||||
|
start_new_session=True, # don't forward signals
|
||||||
)
|
)
|
||||||
self.pid = self.proc.pid
|
self.pid = self.proc.pid
|
||||||
|
|
||||||
|
@@ -7,6 +7,8 @@ import threading
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.concurrent import Future
|
from tornado.concurrent import Future
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
@@ -15,17 +17,20 @@ from ..spawner import LocalProcessSpawner
|
|||||||
from ..app import JupyterHub
|
from ..app import JupyterHub
|
||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..utils import localhost
|
||||||
|
|
||||||
|
from pamela import PAMError
|
||||||
|
|
||||||
def mock_authenticate(username, password, service='login'):
|
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
|
# just use equality for testing
|
||||||
if password == username:
|
if password == username:
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
raise PAMError("Fake")
|
||||||
|
|
||||||
|
|
||||||
|
def mock_open_session(username, service):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MockSpawner(LocalProcessSpawner):
|
class MockSpawner(LocalProcessSpawner):
|
||||||
@@ -49,12 +54,12 @@ class SlowSpawner(MockSpawner):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
|
||||||
yield super().start()
|
yield super().start()
|
||||||
|
yield gen.sleep(2)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self):
|
def stop(self):
|
||||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
yield gen.sleep(2)
|
||||||
yield super().stop()
|
yield super().stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -69,22 +74,44 @@ class NeverSpawner(MockSpawner):
|
|||||||
return Future()
|
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):
|
class MockPAMAuthenticator(PAMAuthenticator):
|
||||||
|
def _admin_users_default(self):
|
||||||
|
return {'admin'}
|
||||||
|
|
||||||
def system_user_exists(self, user):
|
def system_user_exists(self, user):
|
||||||
# skip the add-system-user bit
|
# skip the add-system-user bit
|
||||||
return not user.name.startswith('dne')
|
return not user.name.startswith('dne')
|
||||||
|
|
||||||
def authenticate(self, *args, **kwargs):
|
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)
|
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
||||||
|
|
||||||
class MockHub(JupyterHub):
|
class MockHub(JupyterHub):
|
||||||
"""Hub with various mock bits"""
|
"""Hub with various mock bits"""
|
||||||
|
|
||||||
db_file = None
|
db_file = None
|
||||||
|
|
||||||
def _ip_default(self):
|
def _ip_default(self):
|
||||||
return 'localhost'
|
return localhost()
|
||||||
|
|
||||||
def _authenticator_class_default(self):
|
def _authenticator_class_default(self):
|
||||||
return MockPAMAuthenticator
|
return MockPAMAuthenticator
|
||||||
@@ -92,15 +119,18 @@ class MockHub(JupyterHub):
|
|||||||
def _spawner_class_default(self):
|
def _spawner_class_default(self):
|
||||||
return MockSpawner
|
return MockSpawner
|
||||||
|
|
||||||
def _admin_users_default(self):
|
def init_signal(self):
|
||||||
return {'admin'}
|
pass
|
||||||
|
|
||||||
def start(self, argv=None):
|
def start(self, argv=None):
|
||||||
self.db_file = NamedTemporaryFile()
|
self.db_file = NamedTemporaryFile()
|
||||||
self.db_url = 'sqlite:///' + self.db_file.name
|
self.db_url = 'sqlite:///' + self.db_file.name
|
||||||
|
|
||||||
evt = threading.Event()
|
evt = threading.Event()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def _start_co():
|
def _start_co():
|
||||||
|
assert self.io_loop._running
|
||||||
# put initialize in start for SQLAlchemy threading reasons
|
# put initialize in start for SQLAlchemy threading reasons
|
||||||
yield super(MockHub, self).initialize(argv=argv)
|
yield super(MockHub, self).initialize(argv=argv)
|
||||||
# add an initial user
|
# add an initial user
|
||||||
@@ -108,16 +138,19 @@ class MockHub(JupyterHub):
|
|||||||
self.db.add(user)
|
self.db.add(user)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
yield super(MockHub, self).start()
|
yield super(MockHub, self).start()
|
||||||
|
yield self.hub.server.wait_up(http=True)
|
||||||
self.io_loop.add_callback(evt.set)
|
self.io_loop.add_callback(evt.set)
|
||||||
|
|
||||||
def _start():
|
def _start():
|
||||||
self.io_loop = IOLoop.current()
|
self.io_loop = IOLoop()
|
||||||
|
self.io_loop.make_current()
|
||||||
self.io_loop.add_callback(_start_co)
|
self.io_loop.add_callback(_start_co)
|
||||||
self.io_loop.start()
|
self.io_loop.start()
|
||||||
|
|
||||||
self._thread = threading.Thread(target=_start)
|
self._thread = threading.Thread(target=_start)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
evt.wait(timeout=5)
|
ready = evt.wait(timeout=10)
|
||||||
|
assert ready
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
@@ -126,3 +159,15 @@ class MockHub(JupyterHub):
|
|||||||
# ignore the call that will fire in atexit
|
# ignore the call that will fire in atexit
|
||||||
self.cleanup = lambda : None
|
self.cleanup = lambda : None
|
||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
|
def login_user(self, name):
|
||||||
|
r = requests.post(self.proxy.public_server.url + 'hub/login',
|
||||||
|
data={
|
||||||
|
'username': name,
|
||||||
|
'password': name,
|
||||||
|
},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.cookies
|
||||||
|
return r.cookies
|
||||||
|
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
"""Tests for the REST API"""
|
"""Tests for the REST API"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from queue import Queue
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
|
|
||||||
from ..utils import url_path_join as ujoin
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..user import User
|
||||||
|
from ..utils import url_path_join as ujoin
|
||||||
from . import mocking
|
from . import mocking
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +42,15 @@ def check_db_locks(func):
|
|||||||
def find_user(db, name):
|
def find_user(db, name):
|
||||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||||
|
|
||||||
def add_user(db, **kwargs):
|
def add_user(db, app=None, **kwargs):
|
||||||
user = orm.User(**kwargs)
|
orm_user = orm.User(**kwargs)
|
||||||
db.add(user)
|
db.add(orm_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return user
|
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):
|
def auth_header(db, name):
|
||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
@@ -59,11 +67,15 @@ def api_request(app, *api_path, **kwargs):
|
|||||||
|
|
||||||
if 'Authorization' not in headers:
|
if 'Authorization' not in headers:
|
||||||
headers.update(auth_header(app.db, 'admin'))
|
headers.update(auth_header(app.db, 'admin'))
|
||||||
|
|
||||||
url = ujoin(base_url, 'api', *api_path)
|
url = ujoin(base_url, 'api', *api_path)
|
||||||
method = kwargs.pop('method', 'get')
|
method = kwargs.pop('method', 'get')
|
||||||
f = getattr(requests, method)
|
f = getattr(requests, method)
|
||||||
return f(url, **kwargs)
|
resp = f(url, **kwargs)
|
||||||
|
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
|
||||||
|
assert ujoin(app.hub.server.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
|
||||||
|
assert 'http' not in resp.headers['Content-Security-Policy']
|
||||||
|
return resp
|
||||||
|
|
||||||
def test_auth_api(app):
|
def test_auth_api(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -78,7 +90,7 @@ def test_auth_api(app):
|
|||||||
r = api_request(app, 'authorizations/token', api_token)
|
r = api_request(app, 'authorizations/token', api_token)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply['user'] == user.name
|
assert reply['name'] == user.name
|
||||||
|
|
||||||
# check fail
|
# check fail
|
||||||
r = api_request(app, 'authorizations/token', api_token,
|
r = api_request(app, 'authorizations/token', api_token,
|
||||||
@@ -91,6 +103,51 @@ def test_auth_api(app):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_referer_check(app, io_loop):
|
||||||
|
url = app.hub.server.url
|
||||||
|
host = urlparse(url).netloc
|
||||||
|
user = find_user(app.db, 'admin')
|
||||||
|
if user is None:
|
||||||
|
user = add_user(app.db, name='admin', admin=True)
|
||||||
|
cookies = app.login_user('admin')
|
||||||
|
app_user = get_app_user(app, 'admin')
|
||||||
|
# stop the admin's server so we don't mess up future tests
|
||||||
|
io_loop.run_sync(lambda : app.proxy.delete_user(app_user))
|
||||||
|
io_loop.run_sync(app_user.stop)
|
||||||
|
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': 'null',
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': 'http://attack.com/csrf/vulnerability',
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': url,
|
||||||
|
'Host': host,
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': ujoin(url, 'foo/bar/baz/bat'),
|
||||||
|
'Host': host,
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_get_users(app):
|
def test_get_users(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
r = api_request(app, 'users')
|
r = api_request(app, 'users')
|
||||||
@@ -129,6 +186,93 @@ def test_add_user(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert not user.admin
|
assert not user.admin
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user(app):
|
||||||
|
name = 'user'
|
||||||
|
r = api_request(app, 'users', name)
|
||||||
|
assert r.status_code == 200
|
||||||
|
user = r.json()
|
||||||
|
user.pop('last_activity')
|
||||||
|
assert user == {
|
||||||
|
'name': name,
|
||||||
|
'admin': False,
|
||||||
|
'server': None,
|
||||||
|
'pending': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_multi_user_bad(app):
|
||||||
|
r = api_request(app, 'users', method='post')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = api_request(app, 'users', method='post', data='{}')
|
||||||
|
assert r.status_code == 400
|
||||||
|
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']
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert names == r_names
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
user = find_user(db, name)
|
||||||
|
assert user is not None
|
||||||
|
assert user.name == name
|
||||||
|
assert not user.admin
|
||||||
|
|
||||||
|
# try to create the same users again
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
names = ['a', 'b', 'ab']
|
||||||
|
|
||||||
|
# try to create the same users again
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert r_names == ['ab']
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_multi_user_admin(app):
|
||||||
|
db = app.db
|
||||||
|
names = ['c', 'd']
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names, 'admin': True}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert names == r_names
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
user = find_user(db, name)
|
||||||
|
assert user is not None
|
||||||
|
assert user.name == name
|
||||||
|
assert user.admin
|
||||||
|
|
||||||
|
|
||||||
def test_add_user_bad(app):
|
def test_add_user_bad(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'dne_newuser'
|
name = 'dne_newuser'
|
||||||
@@ -175,17 +319,36 @@ def test_make_admin(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert user.admin
|
assert user.admin
|
||||||
|
|
||||||
|
def get_app_user(app, name):
|
||||||
|
"""Get the User object from the main thread
|
||||||
|
|
||||||
|
Needed for access to the Spawner.
|
||||||
|
No ORM methods should be called on the result.
|
||||||
|
"""
|
||||||
|
q = Queue()
|
||||||
|
def get_user_id():
|
||||||
|
user = find_user(app.db, name)
|
||||||
|
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):
|
def test_spawn(app, io_loop):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
user = add_user(db, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
r = api_request(app, 'users', name, 'server', method='post')
|
options = {
|
||||||
|
's': ['value'],
|
||||||
|
'i': 5,
|
||||||
|
}
|
||||||
|
r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options))
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert 'pid' in user.state
|
assert 'pid' in user.state
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert not user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
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 status is None
|
||||||
|
|
||||||
assert user.server.base_url == '/user/%s' % name
|
assert user.server.base_url == '/user/%s' % name
|
||||||
@@ -203,79 +366,80 @@ def test_spawn(app, io_loop):
|
|||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
assert 'pid' not in user.state
|
assert 'pid' not in user.state
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status == 0
|
assert status == 0
|
||||||
|
|
||||||
def test_slow_spawn(app, io_loop):
|
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_spawn_timeout'] = 0
|
||||||
app.tornado_application.settings['slow_stop_timeout'] = 0
|
app.tornado_application.settings['slow_stop_timeout'] = 0
|
||||||
|
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'zoe'
|
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')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
assert not user.stop_pending
|
assert app_user.spawn_pending
|
||||||
|
assert not app_user.stop_pending
|
||||||
|
|
||||||
dt = timedelta(seconds=0.1)
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_spawn():
|
def wait_spawn():
|
||||||
while user.spawn_pending:
|
while app_user.spawn_pending:
|
||||||
yield gen.Task(io_loop.add_timeout, dt)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
io_loop.run_sync(wait_spawn)
|
io_loop.run_sync(wait_spawn)
|
||||||
assert not user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_stop():
|
def wait_stop():
|
||||||
while user.stop_pending:
|
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 = api_request(app, 'users', name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert user.stop_pending
|
assert app_user.stop_pending
|
||||||
|
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert user.stop_pending
|
assert app_user.stop_pending
|
||||||
|
|
||||||
io_loop.run_sync(wait_stop)
|
io_loop.run_sync(wait_stop)
|
||||||
assert not user.stop_pending
|
assert not app_user.stop_pending
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_never_spawn(app, io_loop):
|
def test_never_spawn(app, io_loop):
|
||||||
app.tornado_application.settings['spawner_class'] = mocking.NeverSpawner
|
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
|
||||||
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
app.tornado_application.settings['slow_spawn_timeout'] = 0
|
||||||
|
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'badger'
|
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')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
|
assert app_user.spawn_pending
|
||||||
|
|
||||||
dt = timedelta(seconds=0.1)
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_pending():
|
def wait_pending():
|
||||||
while user.spawn_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)
|
io_loop.run_sync(wait_pending)
|
||||||
assert not user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is not None
|
assert status is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -284,3 +448,18 @@ def test_get_proxy(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert list(reply.keys()) == ['/']
|
assert list(reply.keys()) == ['/']
|
||||||
|
|
||||||
|
|
||||||
|
def test_shutdown(app):
|
||||||
|
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||||
|
'servers': True,
|
||||||
|
'proxy': True,
|
||||||
|
}))
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
for i in range(100):
|
||||||
|
if app.io_loop._running:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
assert not app.io_loop._running
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from getpass import getuser
|
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
|
||||||
@@ -16,7 +15,9 @@ def test_token_app():
|
|||||||
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
||||||
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
||||||
with TemporaryDirectory() as td:
|
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)
|
assert re.match(r'^[a-z0-9]+$', out)
|
||||||
|
|
||||||
def test_generate_config():
|
def test_generate_config():
|
||||||
|
@@ -3,18 +3,22 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from .mocking import MockPAMAuthenticator
|
from .mocking import MockPAMAuthenticator
|
||||||
|
|
||||||
|
from jupyterhub import auth, orm
|
||||||
|
|
||||||
def test_pam_auth(io_loop):
|
def test_pam_auth(io_loop):
|
||||||
authenticator = MockPAMAuthenticator()
|
authenticator = MockPAMAuthenticator()
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'match',
|
'username': 'match',
|
||||||
'password': 'match',
|
'password': 'match',
|
||||||
}))
|
}))
|
||||||
assert authorized == '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',
|
'username': 'match',
|
||||||
'password': 'nomatch',
|
'password': 'nomatch',
|
||||||
}))
|
}))
|
||||||
@@ -22,20 +26,171 @@ def test_pam_auth(io_loop):
|
|||||||
|
|
||||||
def test_pam_auth_whitelist(io_loop):
|
def test_pam_auth_whitelist(io_loop):
|
||||||
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
|
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',
|
'username': 'kaylee',
|
||||||
'password': 'kaylee',
|
'password': 'kaylee',
|
||||||
}))
|
}))
|
||||||
assert authorized == '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',
|
'username': 'wash',
|
||||||
'password': 'nomatch',
|
'password': 'nomatch',
|
||||||
}))
|
}))
|
||||||
assert authorized is None
|
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',
|
'username': 'mal',
|
||||||
'password': 'mal',
|
'password': 'mal',
|
||||||
}))
|
}))
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
class MockGroup:
|
||||||
|
def __init__(self, *names):
|
||||||
|
self.gr_mem = names
|
||||||
|
|
||||||
|
|
||||||
|
def test_pam_auth_group_whitelist(io_loop):
|
||||||
|
g = MockGroup('kaylee')
|
||||||
|
def getgrnam(name):
|
||||||
|
return g
|
||||||
|
|
||||||
|
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||||
|
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.get_authenticated_user(None, {
|
||||||
|
'username': 'mal',
|
||||||
|
'password': 'mal',
|
||||||
|
}))
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pam_auth_no_such_group(io_loop):
|
||||||
|
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
||||||
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'kaylee',
|
||||||
|
'password': 'kaylee',
|
||||||
|
}))
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wont_add_system_user(io_loop):
|
||||||
|
user = orm.User(name='lioness4321')
|
||||||
|
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||||
|
authenticator.create_system_users = False
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
class DummyFile:
|
||||||
|
def read(self):
|
||||||
|
return b'dummy error'
|
||||||
|
|
||||||
|
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
|
||||||
|
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
class DummyPopen:
|
||||||
|
def __init__(self, cmd, *args, **kwargs):
|
||||||
|
record['cmd'] = cmd
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'Popen', DummyPopen):
|
||||||
|
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||||
|
assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321']
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user(io_loop):
|
||||||
|
user = orm.User(name='zoe')
|
||||||
|
a = MockPAMAuthenticator(whitelist={'mal'})
|
||||||
|
|
||||||
|
assert 'zoe' not in a.whitelist
|
||||||
|
a.add_user(user)
|
||||||
|
assert 'zoe' in a.whitelist
|
||||||
|
a.delete_user(user)
|
||||||
|
assert 'zoe' not in a.whitelist
|
||||||
|
|
||||||
|
|
||||||
|
def test_urls():
|
||||||
|
a = auth.PAMAuthenticator()
|
||||||
|
logout = a.logout_url('/base/url/')
|
||||||
|
login = a.login_url('/base/url')
|
||||||
|
assert logout == '/base/url/logout'
|
||||||
|
assert login == '/base/url/login'
|
||||||
|
|
||||||
|
|
||||||
|
def test_handlers(app):
|
||||||
|
a = auth.PAMAuthenticator()
|
||||||
|
handlers = a.get_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 tornado import gen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from ..user import User
|
||||||
from .mocking import MockSpawner
|
from .mocking import MockSpawner
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ def test_server(db):
|
|||||||
assert isinstance(server.cookie_name, str)
|
assert isinstance(server.cookie_name, str)
|
||||||
assert server.host == 'http://localhost:%i' % server.port
|
assert server.host == 'http://localhost:%i' % server.port
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
|
assert server.bind_url == 'http://*:%i/' % server.port
|
||||||
server.ip = '127.0.0.1'
|
server.ip = '127.0.0.1'
|
||||||
assert server.host == 'http://127.0.0.1:%i' % server.port
|
assert server.host == 'http://127.0.0.1:%i' % server.port
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
@@ -93,8 +95,8 @@ def test_tokens(db):
|
|||||||
|
|
||||||
|
|
||||||
def test_spawn_fails(db, io_loop):
|
def test_spawn_fails(db, io_loop):
|
||||||
user = orm.User(name='aeofel')
|
orm_user = orm.User(name='aeofel')
|
||||||
db.add(user)
|
db.add(orm_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
class BadSpawner(MockSpawner):
|
class BadSpawner(MockSpawner):
|
||||||
@@ -102,8 +104,13 @@ def test_spawn_fails(db, io_loop):
|
|||||||
def start(self):
|
def start(self):
|
||||||
raise RuntimeError("Split the party")
|
raise RuntimeError("Split the party")
|
||||||
|
|
||||||
|
user = User(orm_user, {
|
||||||
|
'spawner_class': BadSpawner,
|
||||||
|
'config': None,
|
||||||
|
})
|
||||||
|
|
||||||
with pytest.raises(Exception) as exc:
|
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 user.server is None
|
||||||
assert not user.running
|
assert not user.running
|
||||||
|
|
||||||
|
149
jupyterhub/tests/test_pages.py
Normal file
149
jupyterhub/tests/test_pages.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..utils import url_path_join as ujoin
|
||||||
|
from .. import orm
|
||||||
|
|
||||||
|
import mock
|
||||||
|
from .mocking import FormSpawner
|
||||||
|
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)
|
||||||
|
print(base_url)
|
||||||
|
return requests.get(ujoin(base_url, path), **kw)
|
||||||
|
|
||||||
|
def test_root_no_auth(app, io_loop):
|
||||||
|
print(app.hub.server.is_up())
|
||||||
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
|
print(routes)
|
||||||
|
print(app.hub.server)
|
||||||
|
r = requests.get(app.proxy.public_server.host)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url == ujoin(app.proxy.public_server.host, 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.raise_for_status()
|
||||||
|
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
|
||||||
|
|
||||||
|
def test_home_no_auth(app):
|
||||||
|
r = get_page('home', app, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert '/hub/login' in r.headers['Location']
|
||||||
|
|
||||||
|
def test_home_auth(app):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
r = get_page('home', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url.endswith('home')
|
||||||
|
|
||||||
|
def test_admin_no_auth(app):
|
||||||
|
r = get_page('admin', app)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_admin_not_admin(app):
|
||||||
|
cookies = app.login_user('wash')
|
||||||
|
r = get_page('admin', app, cookies=cookies)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_admin(app):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
u = orm.User.find(app.db, 'river')
|
||||||
|
u.admin = True
|
||||||
|
app.db.commit()
|
||||||
|
r = get_page('admin', app, cookies=cookies)
|
||||||
|
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(app.proxy.public_server.host, 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(app.proxy.public_server.host, 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'},
|
||||||
|
}
|
||||||
|
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from queue import Queue
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -26,7 +27,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
request.addfinalizer(fin)
|
request.addfinalizer(fin)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
|
||||||
cmd = [app.proxy_cmd,
|
cmd = app.proxy_cmd + [
|
||||||
'--ip', app.ip,
|
'--ip', app.ip,
|
||||||
'--port', str(app.port),
|
'--port', str(app.port),
|
||||||
'--api-ip', proxy_ip,
|
'--api-ip', proxy_ip,
|
||||||
@@ -82,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
new_auth_token = 'different!'
|
new_auth_token = 'different!'
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
|
||||||
proxy_port = 55432
|
proxy_port = 55432
|
||||||
cmd = [app.proxy_cmd,
|
cmd = app.proxy_cmd + [
|
||||||
'--ip', app.ip,
|
'--ip', app.ip,
|
||||||
'--port', str(app.port),
|
'--port', str(app.port),
|
||||||
'--api-ip', app.proxy_api_ip,
|
'--api-ip', app.proxy_api_ip,
|
||||||
@@ -100,7 +101,15 @@ def test_external_proxy(request, io_loop):
|
|||||||
}))
|
}))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert app.proxy.api_server.port == proxy_port
|
assert app.proxy.api_server.port == proxy_port
|
||||||
assert app.proxy.auth_token == new_auth_token
|
|
||||||
|
# get updated auth token from main thread
|
||||||
|
def get_app_proxy_token():
|
||||||
|
q = Queue()
|
||||||
|
app.io_loop.add_callback(lambda : q.put(app.proxy.auth_token))
|
||||||
|
return q.get(timeout=2)
|
||||||
|
|
||||||
|
assert get_app_proxy_token() == new_auth_token
|
||||||
|
app.proxy.auth_token = new_auth_token
|
||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
|
@@ -56,6 +56,30 @@ def test_spawner(db, io_loop):
|
|||||||
status = io_loop.run_sync(spawner.poll)
|
status = io_loop.run_sync(spawner.poll)
|
||||||
assert status == 1
|
assert status == 1
|
||||||
|
|
||||||
|
def test_single_user_spawner(db, io_loop):
|
||||||
|
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
|
||||||
|
io_loop.run_sync(spawner.start)
|
||||||
|
assert spawner.user.server.ip == 'localhost'
|
||||||
|
# wait for http server to come up,
|
||||||
|
# checking for early termination every 1s
|
||||||
|
def wait():
|
||||||
|
return spawner.user.server.wait_up(timeout=1, http=True)
|
||||||
|
for i in range(30):
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status is None
|
||||||
|
try:
|
||||||
|
io_loop.run_sync(wait)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
io_loop.run_sync(wait)
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status == None
|
||||||
|
io_loop.run_sync(spawner.stop)
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status == 0
|
||||||
|
|
||||||
|
|
||||||
def test_stop_spawner_sigint_fails(db, io_loop):
|
def test_stop_spawner_sigint_fails(db, io_loop):
|
||||||
spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible])
|
spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible])
|
||||||
|
27
jupyterhub/tests/test_traitlets.py
Normal file
27
jupyterhub/tests/test_traitlets.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from traitlets import HasTraits
|
||||||
|
|
||||||
|
from jupyterhub.traitlets import URLPrefix, Command
|
||||||
|
|
||||||
|
def test_url_prefix():
|
||||||
|
class C(HasTraits):
|
||||||
|
url = URLPrefix()
|
||||||
|
|
||||||
|
c = C()
|
||||||
|
c.url = '/a/b/c/'
|
||||||
|
assert c.url == '/a/b/c/'
|
||||||
|
c.url = '/a/b'
|
||||||
|
assert c.url == '/a/b/'
|
||||||
|
c.url = 'a/b/c/d'
|
||||||
|
assert c.url == '/a/b/c/d/'
|
||||||
|
|
||||||
|
def test_command():
|
||||||
|
class C(HasTraits):
|
||||||
|
cmd = Command('default command')
|
||||||
|
cmd2 = Command(['default_cmd'])
|
||||||
|
|
||||||
|
c = C()
|
||||||
|
assert c.cmd == ['default command']
|
||||||
|
assert c.cmd2 == ['default_cmd']
|
||||||
|
c.cmd = 'foo bar'
|
||||||
|
assert c.cmd == ['foo bar']
|
||||||
|
|
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from IPython.utils.traitlets import Unicode
|
from traitlets import List, Unicode
|
||||||
|
|
||||||
class URLPrefix(Unicode):
|
class URLPrefix(Unicode):
|
||||||
def validate(self, obj, value):
|
def validate(self, obj, value):
|
||||||
@@ -12,3 +12,18 @@ class URLPrefix(Unicode):
|
|||||||
if not u.endswith('/'):
|
if not u.endswith('/'):
|
||||||
u = u + '/'
|
u = u + '/'
|
||||||
return u
|
return u
|
||||||
|
|
||||||
|
class Command(List):
|
||||||
|
"""Traitlet for a command that should be a list of strings,
|
||||||
|
but allows it to be specified as a single string.
|
||||||
|
"""
|
||||||
|
def __init__(self, default_value=None, **kwargs):
|
||||||
|
kwargs.setdefault('minlen', 1)
|
||||||
|
if isinstance(default_value, str):
|
||||||
|
default_value = [default_value]
|
||||||
|
super().__init__(Unicode, default_value, **kwargs)
|
||||||
|
|
||||||
|
def validate(self, obj, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = [value]
|
||||||
|
return super().validate(obj, value)
|
||||||
|
265
jupyterhub/user.py
Normal file
265
jupyterhub/user.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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):
|
||||||
|
|
||||||
|
def _log_default(self):
|
||||||
|
return app_log
|
||||||
|
|
||||||
|
settings = Dict()
|
||||||
|
|
||||||
|
db = Any(allow_none=True)
|
||||||
|
def _db_default(self):
|
||||||
|
if self.orm_user:
|
||||||
|
return inspect(self.orm_user).session
|
||||||
|
|
||||||
|
def _db_changed(self, name, old, new):
|
||||||
|
"""Changing db session reacquires ORM User object"""
|
||||||
|
# db session changed, re-get orm User
|
||||||
|
if self.orm_user:
|
||||||
|
id = self.orm_user.id
|
||||||
|
self.orm_user = new.query(orm.User).filter(orm.User.id==id).first()
|
||||||
|
self.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.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='@')
|
||||||
|
|
||||||
|
@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.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
|
||||||
|
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,16 +6,17 @@
|
|||||||
from binascii import b2a_hex
|
from binascii import b2a_hex
|
||||||
import errno
|
import errno
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from hmac import compare_digest
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
from threading import Thread
|
||||||
import uuid
|
import uuid
|
||||||
|
import warnings
|
||||||
|
|
||||||
from tornado import web, gen, ioloop
|
from tornado import web, gen, ioloop
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
from IPython.html.utils import url_path_join
|
|
||||||
|
|
||||||
|
|
||||||
def random_port():
|
def random_port():
|
||||||
"""get a single random port"""
|
"""get a single random port"""
|
||||||
@@ -42,7 +43,7 @@ def wait_for_server(ip, port, timeout=10):
|
|||||||
app_log.error("Unexpected error waiting for %s:%i %s",
|
app_log.error("Unexpected error waiting for %s:%i %s",
|
||||||
ip, port, e
|
ip, port, e
|
||||||
)
|
)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.1)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
||||||
@@ -68,14 +69,14 @@ def wait_for_http_server(url, timeout=10):
|
|||||||
# we expect 599 for no connection,
|
# we expect 599 for no connection,
|
||||||
# but 502 or other proxy error is conceivable
|
# but 502 or other proxy error is conceivable
|
||||||
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||||
return
|
return
|
||||||
except (OSError, socket.error) as e:
|
except (OSError, socket.error) as e:
|
||||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
||||||
app_log.warn("Failed to connect to %s (%s)", url, e)
|
app_log.warn("Failed to connect to %s (%s)", url, e)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -165,9 +166,64 @@ def compare_token(compare, token):
|
|||||||
uses the same algorithm and salt of the hashed token for comparison
|
uses the same algorithm and salt of the hashed token for comparison
|
||||||
"""
|
"""
|
||||||
algorithm, srounds, salt, _ = compare.split(':')
|
algorithm, srounds, salt, _ = compare.split(':')
|
||||||
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm)
|
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm).encode('utf8')
|
||||||
if compare == hashed:
|
compare = compare.encode('utf8')
|
||||||
|
if compare_digest(compare, hashed):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def url_path_join(*pieces):
|
||||||
|
"""Join components of url into a relative url
|
||||||
|
|
||||||
|
Use to prevent double slash when joining subpath. This will leave the
|
||||||
|
initial and final / in place
|
||||||
|
|
||||||
|
Copied from notebook.utils.url_path_join
|
||||||
|
"""
|
||||||
|
initial = pieces[0].startswith('/')
|
||||||
|
final = pieces[-1].endswith('/')
|
||||||
|
stripped = [ s.strip('/') for s in pieces ]
|
||||||
|
result = '/'.join(s for s in stripped if s)
|
||||||
|
|
||||||
|
if initial:
|
||||||
|
result = '/' + result
|
||||||
|
if final:
|
||||||
|
result = result + '/'
|
||||||
|
if result == '//':
|
||||||
|
result = '/'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def localhost():
|
||||||
|
"""Return localhost or 127.0.0.1"""
|
||||||
|
if hasattr(localhost, '_localhost'):
|
||||||
|
return localhost._localhost
|
||||||
|
binder = connector = None
|
||||||
|
try:
|
||||||
|
binder = socket.socket()
|
||||||
|
binder.bind(('localhost', 0))
|
||||||
|
binder.listen(1)
|
||||||
|
port = binder.getsockname()[1]
|
||||||
|
def accept():
|
||||||
|
try:
|
||||||
|
conn, addr = binder.accept()
|
||||||
|
except ConnectionAbortedError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
conn.close()
|
||||||
|
t = Thread(target=accept)
|
||||||
|
t.start()
|
||||||
|
connector = socket.create_connection(('localhost', port), timeout=10)
|
||||||
|
t.join(timeout=10)
|
||||||
|
except (socket.error, socket.gaierror) as e:
|
||||||
|
warnings.warn("localhost doesn't appear to work, using 127.0.0.1\n%s" % e, RuntimeWarning)
|
||||||
|
localhost._localhost = '127.0.0.1'
|
||||||
|
else:
|
||||||
|
localhost._localhost = 'localhost'
|
||||||
|
finally:
|
||||||
|
if binder:
|
||||||
|
binder.close()
|
||||||
|
if connector:
|
||||||
|
connector.close()
|
||||||
|
return localhost._localhost
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
version_info = (
|
version_info = (
|
||||||
0,
|
0,
|
||||||
1,
|
4,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 @@
|
|||||||
ipython>=3
|
traitlets>=4
|
||||||
tornado>=4
|
tornado>=4.1
|
||||||
jinja2
|
jinja2
|
||||||
simplepam
|
pamela
|
||||||
sqlalchemy
|
sqlalchemy>=1.0
|
||||||
requests
|
requests
|
||||||
|
2
scripts/jupyterhub
Normal file → Executable file
2
scripts/jupyterhub
Normal file → Executable file
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from jupyterhub.app import main
|
from jupyterhub.app import main
|
||||||
main()
|
main()
|
||||||
|
239
scripts/jupyterhub-singleuser
Normal file → Executable file
239
scripts/jupyterhub-singleuser
Normal file → Executable file
@@ -1,4 +1,237 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
"""Extend regular notebook server to be aware of multiuser things."""
|
||||||
|
|
||||||
from jupyterhub.singleuser import main
|
# Copyright (c) Jupyter Development Team.
|
||||||
main()
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import notebook
|
||||||
|
# 4.x
|
||||||
|
except ImportError:
|
||||||
|
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__)
|
||||||
|
else:
|
||||||
|
from notebook.notebookapp import NotebookApp, aliases as notebook_aliases
|
||||||
|
from notebook.auth.login import LoginHandler
|
||||||
|
from notebook.auth.logout import LogoutHandler
|
||||||
|
|
||||||
|
from notebook.utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
|
# Define two methods to attach to AuthenticatedHandler,
|
||||||
|
# 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()
|
||||||
|
1
setup.py
1
setup.py
@@ -165,6 +165,7 @@ class Bower(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.should_run_npm():
|
if self.should_run_npm():
|
||||||
|
print("installing build dependencies with npm")
|
||||||
check_call(['npm', 'install'], cwd=here)
|
check_call(['npm', 'install'], cwd=here)
|
||||||
os.utime(self.node_modules)
|
os.utime(self.node_modules)
|
||||||
|
|
||||||
|
BIN
share/jupyter/hub/static/images/jupyter.png
Normal file
BIN
share/jupyter/hub/static/images/jupyter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 KiB |
@@ -42,7 +42,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
$("th").map(function (i, th) {
|
$("th").map(function (i, th) {
|
||||||
th = $(th);
|
th = $(th);
|
||||||
var col = th.data('sort');
|
var col = th.data('sort');
|
||||||
if (!col || col.length == 0) {
|
if (!col || col.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc';
|
var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc';
|
||||||
@@ -50,7 +50,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
function () {
|
function () {
|
||||||
resort(col, order);
|
resort(col, order);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".time-col").map(function (i, el) {
|
$(".time-col").map(function (i, el) {
|
||||||
@@ -161,9 +161,17 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
|
|
||||||
$("#add-user-dialog").find(".save-button").click(function () {
|
$("#add-user-dialog").find(".save-button").click(function () {
|
||||||
var dialog = $("#add-user-dialog");
|
var dialog = $("#add-user-dialog");
|
||||||
var username = dialog.find(".username-input").val();
|
var lines = dialog.find(".username-input").val().split('\n');
|
||||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||||
api.add_user(username, {admin: admin}, {
|
var usernames = [];
|
||||||
|
lines.map(function (line) {
|
||||||
|
var username = line.trim();
|
||||||
|
if (username.length) {
|
||||||
|
usernames.push(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.add_users(usernames, {admin: admin}, {
|
||||||
success: function () {
|
success: function () {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
@@ -72,18 +72,16 @@ define(['jquery', 'utils'], function ($, utils) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.add_user = function (user, userinfo, options) {
|
JHAPI.prototype.add_users = function (usernames, userinfo, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
var data = update(userinfo, {usernames: usernames});
|
||||||
options = update(options, {
|
options = update(options, {
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
dataType: null,
|
dataType: null,
|
||||||
data: JSON.stringify(userinfo)
|
data: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.api_request(
|
this.api_request('users', options);
|
||||||
utils.url_path_join('users', user),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
||||||
|
@@ -10,17 +10,12 @@ div.ajax-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.error > h1 {
|
div.error > h1 {
|
||||||
font-size: 500%;
|
font-size: 300%;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.error > p {
|
div.error > p {
|
||||||
font-size: 200%;
|
font-size: 200%;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.traceback-wrapper {
|
|
||||||
text-align: left;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
@@ -1,33 +1,55 @@
|
|||||||
#login-main {
|
#login-main {
|
||||||
|
display: table;
|
||||||
|
height: 80vh;
|
||||||
|
|
||||||
|
.service-login {
|
||||||
|
text-align: center;
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: auto auto 20% auto;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
margin: 8px auto;
|
display: table-cell;
|
||||||
width: 400px;
|
vertical-align: middle;
|
||||||
padding: 50px;
|
margin: auto auto 20% auto;
|
||||||
border: 1px solid #ccc;
|
width: 350px;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
.input-group, input[type=text], button {
|
||||||
border-radius: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group, input, button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group-addon {
|
input[type=submit] {
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pwd-group {
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button[type=submit] {
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control:focus, input[type=submit]:focus {
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @jupyter-orange;
|
||||||
|
border-color: @jupyter-orange;
|
||||||
|
outline-color: @jupyter-orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_error {
|
||||||
|
color: orangered;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-header {
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: #fff;
|
||||||
|
background: @jupyter-orange;
|
||||||
|
border-radius: @border-radius-large @border-radius-large 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-body {
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: thin silver solid;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 @border-radius-large @border-radius-large;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
div.logout-main {
|
|
||||||
margin: 2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logout-main > h1 {
|
|
||||||
font-size: 400%;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logout-main > p {
|
|
||||||
font-size: 200%;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
@@ -1,12 +1,26 @@
|
|||||||
.jpy-logo {
|
.jpy-logo {
|
||||||
height: 40px;
|
height: 28px;
|
||||||
margin: 8px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#header {
|
#header {
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #e7e7e7;
|
||||||
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown.navbar-btn{
|
||||||
|
padding:0 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_widget{
|
||||||
|
|
||||||
|
& .navbar-btn.btn-sm {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@@ -24,5 +24,4 @@
|
|||||||
@import "./page.less";
|
@import "./page.less";
|
||||||
@import "./admin.less";
|
@import "./admin.less";
|
||||||
@import "./error.less";
|
@import "./error.less";
|
||||||
@import "./logout.less";
|
|
||||||
@import "./login.less";
|
@import "./login.less";
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
@border-radius-small: 2px;
|
||||||
|
@border-radius-base: 2px;
|
||||||
|
@border-radius-large: 3px;
|
||||||
|
@navbar-height: 20px;
|
||||||
|
|
||||||
|
@jupyter-orange: #F37524;
|
||||||
|
@jupyter-red: #E34F21;
|
||||||
|
|
||||||
|
.btn-jupyter {
|
||||||
|
.button-variant(#fff; @jupyter-orange; @jupyter-red);
|
||||||
|
}
|
||||||
|
@@ -22,18 +22,18 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% block thead %}
|
{% block thead %}
|
||||||
{{ th("User (%i)" % users.count(), 'name') }}
|
{{ th("User (%i)" % users|length, 'name') }}
|
||||||
{{ th("Admin", 'admin') }}
|
{{ th("Admin", 'admin') }}
|
||||||
{{ th("Last Seen", 'last_activity') }}
|
{{ th("Last Seen", 'last_activity') }}
|
||||||
{{ th("Running (%i)" % running.count(), 'running', colspan=2) }}
|
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
|
||||||
{% endblock thead %}
|
{% endblock thead %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="user-row add-user-row">
|
<tr class="user-row add-user-row">
|
||||||
<td colspan="5">
|
<td colspan="12">
|
||||||
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
||||||
<a id="shutdown-hub" class="col-xs-4 col-xs-offset-3 btn btn-danger">Shutdown Hub</a>
|
<a id="shutdown-hub" class="col-xs-5 col-xs-offset-2 btn btn-danger">Shutdown Hub</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
@@ -42,15 +42,19 @@
|
|||||||
<td class="name-col col-sm-2">{{u.name}}</td>
|
<td class="name-col col-sm-2">{{u.name}}</td>
|
||||||
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
||||||
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
|
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
|
||||||
<td class="server-col col-sm-3 text-center">
|
<td class="server-col col-sm-2 text-center">
|
||||||
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
||||||
|
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
||||||
|
</td>
|
||||||
|
<td class="server-col col-sm-1 text-center">
|
||||||
{% if admin_access %}
|
{% if admin_access %}
|
||||||
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-2">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
<span class="edit-user btn btn-xs btn-primary">edit</span>
|
<span class="edit-user btn btn-xs btn-primary">edit</span>
|
||||||
|
</td>
|
||||||
|
<td class="edit-col col-sm-1 text-center">
|
||||||
{% if u.name != user.name %}
|
{% if u.name != user.name %}
|
||||||
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -69,7 +73,7 @@
|
|||||||
|
|
||||||
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
|
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
|
||||||
Are you sure you want to shutdown the Hub?
|
Are you sure you want to shutdown the Hub?
|
||||||
You can 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">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" class="shutdown-proxy-checkbox">Shutdown proxy
|
<input type="checkbox" class="shutdown-proxy-checkbox">Shutdown proxy
|
||||||
@@ -82,10 +86,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% macro user_modal(name) %}
|
{% macro user_modal(name, multi=False) %}
|
||||||
{% call modal(name, btn_class='btn-primary save-button') %}
|
{% call modal(name, btn_class='btn-primary save-button') %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control username-input" placeholder="username">
|
<{%- if multi -%}
|
||||||
|
textarea
|
||||||
|
{%- else -%}
|
||||||
|
input type="text"
|
||||||
|
{%- endif %}
|
||||||
|
class="form-control username-input"
|
||||||
|
placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}">
|
||||||
|
{%- if multi -%}</textarea>{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
@@ -97,7 +108,7 @@
|
|||||||
|
|
||||||
{{ user_modal('Edit User') }}
|
{{ user_modal('Edit User') }}
|
||||||
|
|
||||||
{{ user_modal('Add User') }}
|
{{ user_modal('Add User', multi=True) }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -6,17 +6,18 @@
|
|||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<div class="error">
|
<div class="error">
|
||||||
{% block h1_error %}
|
{% block h1_error %}
|
||||||
<h1>{{status_code}} : {{status_message}}</h1>
|
<h1>
|
||||||
{% endblock h1_error %}
|
{{status_code}} : {{status_message}}
|
||||||
{% block error_detail %}
|
</h1>
|
||||||
{% if message %}
|
{% endblock h1_error %}
|
||||||
<p>The error was:</p>
|
{% block error_detail %}
|
||||||
<div class="traceback-wrapper">
|
{% if message %}
|
||||||
<pre class="traceback">{{message}}</pre>
|
<p>
|
||||||
</div>
|
{{message}}
|
||||||
{% endif %}
|
</p>
|
||||||
{% endblock %}
|
{% endif %}
|
||||||
|
{% endblock error_detail %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -9,8 +9,15 @@
|
|||||||
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
|
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a id="start" class="btn btn-lg btn-success"
|
<a id="start" class="btn btn-lg btn-success"
|
||||||
|
{% if user.running %}
|
||||||
href="{{base_url}}user/{{user.name}}/"
|
href="{{base_url}}user/{{user.name}}/"
|
||||||
|
{% else %}
|
||||||
|
href="{{base_url}}spawn"
|
||||||
|
{% endif %}
|
||||||
>
|
>
|
||||||
|
{% if not user.running %}
|
||||||
|
Start
|
||||||
|
{% endif %}
|
||||||
My Server
|
My Server
|
||||||
</a>
|
</a>
|
||||||
{% if user.admin %}
|
{% if user.admin %}
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
{% extends "page.html" %}
|
|
||||||
|
|
||||||
{% block login_widget %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="text-center">
|
|
||||||
<a id="login" class="btn btn-lg btn-primary" href="{{login_url}}">Log in</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@@ -5,34 +5,63 @@
|
|||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
{% block login %}
|
||||||
<div id="login-main" class="container">
|
<div id="login-main" class="container">
|
||||||
{% if custom_html %}
|
{% if custom_html %}
|
||||||
{{custom_html}}
|
{{ custom_html }}
|
||||||
|
{% elif login_service %}
|
||||||
|
<div class="service-login">
|
||||||
|
<a class='btn btn-jupyter btn-lg' href='{{login_url}}'>
|
||||||
|
Sign in with {{login_service}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
||||||
<div class="input-group">
|
<div class="auth-form-header">
|
||||||
<span class="input-group-addon">Username:</span>
|
Sign in
|
||||||
<input type="username" class="form-control" name="username" id="username_input" val="{{username}}">
|
|
||||||
</div>
|
|
||||||
<div class="input-group pwd-group">
|
|
||||||
<span class="input-group-addon">Password:</span>
|
|
||||||
<input type="password" class="form-control" name="password" id="password_input">
|
|
||||||
</div>
|
|
||||||
<button type="submit" id="login_submit" class="btn btn-default">Log in</button>
|
|
||||||
</form>
|
|
||||||
{% if message %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="message">
|
|
||||||
{{message}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class='auth-form-body'>
|
||||||
|
{% if login_error %}
|
||||||
|
<p class="login_error">
|
||||||
|
{{login_error}}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<label for="username_input">Username:</label>
|
||||||
|
<input
|
||||||
|
id="username_input"
|
||||||
|
type="username"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
class="form-control"
|
||||||
|
name="username"
|
||||||
|
val="{{username}}"
|
||||||
|
tabindex="1"
|
||||||
|
autofocus="autofocus"
|
||||||
|
/>
|
||||||
|
<label for='password_input'>Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
name="password"
|
||||||
|
id="password_input"
|
||||||
|
tabindex="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
id="login_submit"
|
||||||
|
class='btn btn-jupyter'
|
||||||
|
value='Sign In'
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock login %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{super()}}
|
{{super()}}
|
||||||
|
|
||||||
|
@@ -1,13 +0,0 @@
|
|||||||
{% extends "page.html" %}
|
|
||||||
|
|
||||||
{% block login_widget %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
|
|
||||||
<div class="container logout-main">
|
|
||||||
<h1>You have been logged out</h1>
|
|
||||||
<p><a href="{{login_url}}">Log in again...</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@@ -82,15 +82,15 @@
|
|||||||
|
|
||||||
<div id="header" class="navbar navbar-static-top">
|
<div id="header" class="navbar navbar-static-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span id="jupyterhub-logo" class="pull-left"><a href="{{base_url}}"><img src='{{static_url("images/jupyterhub-80.png") }}' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
|
<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>
|
||||||
|
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
|
|
||||||
<span id="login_widget">
|
<span id="login_widget">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a id="logout" class="btn navbar-btn btn-default pull-right" href="{{logout_url}}">Logout</a>
|
<a id="logout" class="navbar-btn btn-sm btn btn-default pull-right" href="{{logout_url}}"> <i class="fa fa-sign-out"></i> Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a id="login" class="btn navbar-btn btn-default pull-right" href="{{login_url}}">Login</a>
|
<a id="login" class="btn-sm btn navbar-btn btn-default pull-right" href="{{login_url}}">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
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 %}
|
Reference in New Issue
Block a user