Compare commits

...

64 Commits

Author SHA1 Message Date
Min RK
e7eb674a89 0.9.0b3 2018-05-23 16:30:07 +02:00
Min RK
b232633100 Merge pull request #1894 from minrk/db-rollback
Rollback database sessions on SQLAlchemy errors
2018-05-23 16:09:51 +02:00
Carol Willing
6abd19c149 Merge pull request #1911 from minrk/log-classes
log Authenticator and Spawner classes at startup
2018-05-22 11:50:59 -07:00
Min RK
0aa0ff8db7 Merge pull request #1912 from minrk/double-slash
Fix login redirect checking for `//` urls
2018-05-22 15:56:29 +02:00
Min RK
a907429fd4 more test cases for login redirects 2018-05-22 15:40:27 +02:00
Min RK
598b550a67 fix query/hash login redirect handling 2018-05-22 15:40:14 +02:00
Min RK
92bb442494 more robust checking for login redirects outside jupyterhub 2018-05-22 15:40:00 +02:00
Min RK
2d41f6223e log Authenticator and Spawner classes at startup
for better diagnostics
2018-05-22 13:52:41 +02:00
Min RK
791dd5fb9f Merge pull request #1895 from minrk/oauth-commits
avoid creating one huge transaction cleaning up oauth clients
2018-05-22 13:37:56 +02:00
Carol Willing
9a0ccf4c98 Merge pull request #1910 from minrk/ip-typo
default bind url should be on all ips
2018-05-22 01:26:35 -07:00
Min RK
ad2abc5771 default bind url should be on all ips
preserves jupyterhub default behavior

typo introduced in new bind_url config
2018-05-22 09:55:01 +02:00
Min RK
2d99b3943f enable pessimistic connection handling
from the sqlalchemy docs

checks if a connection is valid via `SELECT 1` prior to using it.

Since we have long-running connections, this helps us survive database restarts, disconnects, etc.
2018-05-21 22:14:11 +02:00
Min RK
a358132f95 remove --rm from docker-db.sh
for easier stop/start testing
2018-05-21 22:12:30 +02:00
Tim Head
09cd37feee Merge pull request #1896 from thoralf-gutierrez/fix-typos-in-config
Fix typos in auth config documentation
2018-05-16 22:37:51 +02:00
Thoralf Gutierrez
0f3610e81d Fix typos in auth config documentation 2018-05-16 10:58:02 -07:00
Min RK
3f97c438e2 avoid creating one huge transaction cleaning up oauth clients 2018-05-15 16:33:50 +02:00
Min RK
42351201d2 back to dev 2018-05-15 16:32:24 +02:00
Min RK
907bbb8e9d 0.9.0b2 2018-05-15 14:03:10 +02:00
Min RK
63f3d8b621 catch database errors in update_last_activity 2018-05-15 13:53:05 +02:00
Min RK
47d6e841fd cache get_current_user result
avoids raising an error rendering templates, etc.
2018-05-15 13:49:38 +02:00
Min RK
e3bb09fabe rollback database session on db errors
ensures reconnect will occur when database connection is lost
2018-05-15 13:49:14 +02:00
Carol Willing
d4e0c01189 Merge pull request #1893 from minrk/version
ensure jupyterhub version matches pep440
2018-05-15 07:40:24 -04:00
Min RK
50370d42b0 ensure jupyterhub version matches pep440
avoids mismatch jupyterhub version and tag in docker builds
2018-05-15 13:19:43 +02:00
Min RK
aa190a80b7 Merge pull request #1891 from minrk/base_url
fix and test bind_url / base_url interactions
2018-05-15 12:07:44 +01:00
Min RK
e48bae77aa Merge pull request #1890 from minrk/default-url
test default_url handling
2018-05-15 10:51:17 +01:00
Min RK
96cf0f99ed fix and test bind_url / base_url interactions 2018-05-15 10:51:11 +02:00
Min RK
f380968049 test default_url handling
- default_url is used even if not logged in
- flesh out docstrings
- pass via settings
2018-05-15 10:15:33 +02:00
Min RK
02468f4625 Merge pull request #1854 from summerswallow-whi/extra_handler
Add custom handlers and allow setting of defaults
2018-05-15 08:55:15 +01:00
Haw-minn Lu
24611f94cf Remove base_url from default_url
Add help to new traits
change extra_page_handler to extra_handler
2018-05-14 11:53:22 -07:00
Min RK
dc75a9a4b7 Merge pull request #1881 from paccorsi/check-post-stop-hook
Check the value of post stop hook
2018-05-14 13:31:33 +01:00
Min RK
33f459a23a Merge pull request #1878 from ausecocloud/master
fix listing of OAuth tokens on tokens page
2018-05-14 13:31:06 +01:00
Min RK
bdcc251002 Merge pull request #1882 from dhirschfeld/patch-1
Allow configuring the heading in spawn.html
2018-05-14 13:30:47 +01:00
Pierre Accorsi
86052ba7b4 Check the value of post stop hook 2018-05-11 10:12:45 -04:00
Dave Hirschfeld
62ebcf55c9 Allow configuring the heading in spawn.html 2018-05-11 13:34:17 +10:00
Haw-minn Lu
80ac2475a0 Restore whitespacing to original 2018-05-10 11:25:02 -07:00
Haw-minn Lu
5179d922f5 Clean up extra handler defaults 2018-05-10 11:22:50 -07:00
Gerhard Weis
26f085a8ed add test for oauth tokens on tokens page 2018-05-10 08:46:28 +10:00
Gerhard Weis
b7d302cc72 fix listing of OAuth tokens on tokens page 2018-05-10 08:46:28 +10:00
Carol Willing
f2941e3631 Merge pull request #1873 from minrk/apitoken-expiry
implement API token expiry
2018-05-09 11:45:41 -04:00
Carol Willing
26a6401af4 Merge pull request #1876 from willingc/sudo-section
refactor sudo example config
2018-05-08 09:23:28 -07:00
Carol Willing
5c8ce338a1 edit per @minrk review 2018-05-08 11:54:38 -04:00
Carol Willing
5addc7bbaf correct directive 2018-05-07 21:03:13 -07:00
Carol Willing
da095170bf remove toctree item 2018-05-07 20:38:15 -07:00
Carol Willing
1aab0a69bd fix typo 2018-05-07 20:31:20 -07:00
Carol Willing
fc8e04b62f reflow templates file 2018-05-07 20:29:13 -07:00
Carol Willing
c6c53b4e10 update index 2018-05-07 20:28:55 -07:00
Carol Willing
9b0219a2d8 break up configuration examples 2018-05-07 20:18:02 -07:00
Carol Willing
6e212fa476 reflow proxy doc 2018-05-07 20:17:14 -07:00
Carol Willing
58f9237b12 refactor sudo example config 2018-05-07 15:38:16 -07:00
Carol Willing
74fd925219 Merge pull request #1864 from datalayer-contrib/docs-sudo
Add Docs about sudo (and remove it from the wiki)
2018-05-07 23:29:08 +02:00
Carol Willing
2696bb97d2 Merge pull request #1875 from willingc/api-redux
add packages to environment.yml
2018-05-07 23:16:53 +02:00
Haw-minn Lu
9cefb27704 Move extra_handlers to fall below builtins in priority 2018-05-07 14:06:34 -07:00
Carol Willing
5e75357b06 add packages to environment.yml 2018-05-07 13:54:06 -07:00
Min RK
79bebb4bc9 Merge pull request #1872 from thedataincubator/template-vars
Allow extra variables to be passed into templates
2018-05-07 20:33:44 +02:00
Eric Charles
0ed88f212b add sudo.md 2018-05-07 19:49:26 +02:00
Eric Charles
a8c1cab5fe add sudo doc 2018-05-07 19:49:26 +02:00
Min RK
e1a6b1a70f Merge pull request #1856 from minrk/whoami-users
note about hub_users in whoami example
2018-05-07 19:47:45 +02:00
Robert Schroll
c95ed16786 Allow extra variables to be passed into templates 2018-05-07 10:47:27 -07:00
Min RK
ec784803b4 remove duplicate whoami-oauth.py from external-oauth example 2018-05-07 15:35:05 +02:00
Min RK
302d7a22d3 leave user-whitelist example in a comment
allow all users by default because default whitelist is confusing
2018-05-07 15:34:33 +02:00
Min RK
7c6591aefe add token expiry to token model 2018-05-07 13:02:26 +02:00
Min RK
58c91e3fd4 implement API token expiry 2018-05-07 13:00:37 +02:00
Min RK
db4cf7ae62 note about hub_users in whoami example
explain what hub_users does and the value in the example
2018-05-07 10:55:39 +02:00
Haw-minn Lu
bc86ee1c31 Add custom handlers and allow setting of defaults 2018-04-27 15:58:59 -07:00
32 changed files with 1130 additions and 470 deletions

View File

@@ -8,7 +8,7 @@ export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
export PGHOST=127.0.0.1
NAME="hub-test-$DB"
DOCKER_RUN="docker run --rm -d --name $NAME"
DOCKER_RUN="docker run -d --name $NAME"
docker rm -f "$NAME" 2>/dev/null || true
@@ -47,4 +47,4 @@ Set these environment variables:
export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=$MYSQL_TCP_PORT
export PGHOST=127.0.0.1
"
"

View File

@@ -15,3 +15,5 @@ dependencies:
- pip:
- python-oauth2
- recommonmark==0.4.0
- async_generator
- prometheus_client

View File

@@ -51,7 +51,7 @@ and tornado < 5.0.
Sets ip, port, base_url all at once.
- Add `JupyterHub.hub_bind_url` for setting the full host+port of the Hub.
`hub_bind_url` supports unix domain sockets, e.g.
`unix+http://%2Fsrv%2Fjupytrehub.sock`
`unix+http://%2Fsrv%2Fjupyterhub.sock`
- Deprecate `JupyterHub.hub_connect_port` config in favor of `JupyterHub.hub_connect_url`. `hub_connect_ip` is not deprecated
and can still be used in the common case where only the ip address of the hub differs from the bind ip.

View File

@@ -59,6 +59,9 @@ Contents
* :doc:`reference/rest`
* :doc:`reference/upgrading`
* :doc:`reference/config-examples`
* :doc:`reference/config-ghoauth`
* :doc:`reference/config-proxy`
* :doc:`reference/config-sudo`
**API Reference**

View File

@@ -1,281 +1,8 @@
# Configuration examples
This section provides examples, including configuration files and tips, for the
following configurations:
The following sections provide examples, including configuration files and tips, for the
following:
- Using GitHub OAuth
- Using nginx reverse proxy
## Using GitHub OAuth
In this example, we show a configuration file for a fairly standard JupyterHub
deployment with the following assumptions:
* Running JupyterHub on a single cloud server
* Using SSL on the standard HTTPS port 443
* Using GitHub OAuth (using oauthenticator) for login
* Using the default spawner (to configure other spawners, uncomment and edit
`spawner_class` as well as follow the instructions for your desired spawner)
* Users exist locally on the server
* 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`.
The `jupyterhub_config.py` file would have these settings:
```python
# jupyterhub_config.py file
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)
# Allows multiple single-server per user
c.JupyterHub.allow_named_servers = True
# 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
# 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'}
# uses the default spawner
# To use a different spawner, uncomment `spawner_class` and set to desired
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
# configuration.
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/jupyter/jupyter_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
environment variable to be set prior to launching JupyterHub:
```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
# append log output to log file /var/log/jupyterhub.log
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
```
## Using a reverse proxy
In the following example, we show configuration files for a JupyterHub server
running locally on port `8000` but accessible from the outside on the standard
SSL port `443`. This could be useful if the JupyterHub server machine is also
hosting other domains or content on `443`. The goal in this example is to
satisfy the following:
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
also on port `443`
* `nginx` or `apache` is used as the public access point (which means that
only nginx/apache will bind to `443`)
* After testing, the server in question should be able to score at least an A on the
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
```python
# Force the proxy to only listen to connections to 127.0.0.1
c.JupyterHub.ip = '127.0.0.1'
```
For high-quality SSL configuration, we also generate Diffie-Helman parameters.
This can take a few minutes:
```bash
openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
```
### nginx
The **`nginx` server config file** is fairly standard fare except for the two
`location` blocks within the `HUB.DOMAIN.TLD` config file:
```bash
# top-level http config for websocket headers
# If Upgrade is defined, Connection = upgrade
# If Upgrade is empty, Connection = close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP server to redirect all 80 traffic to SSL/HTTPS
server {
listen 80;
server_name HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
# HTTPS server to handle JupyterHub
server {
listen 443;
ssl on;
server_name HUB.DOMAIN.TLD;
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security max-age=15768000;
# Managing literal requests to the JupyterHub front end
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# websocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
If `nginx` is not running on port 443, substitute `$http_host` for `$host` on
the lines setting the `Host` header.
`nginx` will now be the front facing element of JupyterHub on `443` which means
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
on the same machine and network interface. In fact, one can simply use the same
server blocks as above for `NO_HUB` and simply add line for the root directory
of the site as well as the applicable location call:
```bash
server {
listen 80;
server_name NO_HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
server {
listen 443;
ssl on;
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
# SSL cert may differ
# Set the appropriate root directory
root /var/www/html
# Set URI handling
location / {
try_files $uri $uri/ =404;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
Now restart `nginx`, restart the JupyterHub, and enjoy accessing
`https://HUB.DOMAIN.TLD` while serving other content securely on
`https://NO_HUB.DOMAIN.TLD`.
### Apache
As with nginx above, you can use [Apache](https://httpd.apache.org) as the reverse proxy.
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
- Redirect HTTP to HTTPS
- Good SSL Configuration
- Support for websockets on any proxied URL
- JupyterHub is running locally at http://127.0.0.1:8000
```bash
# redirect HTTP to HTTPS
Listen 80
<VirtualHost HUB.DOMAIN.TLD:80>
ServerName HUB.DOMAIN.TLD
Redirect / https://HUB.DOMAIN.TLD/
</VirtualHost>
Listen 443
<VirtualHost HUB.DOMAIN.TLD:443>
ServerName HUB.DOMAIN.TLD
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
RewriteCond %{HTTP:Connection} Upgrade [NC]
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:8000/$1 [P,L]
<Location "/">
# preserve Host header to avoid cross-origin problems
ProxyPreserveHost on
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
</Location>
</VirtualHost>
```
- Configuring GitHub OAuth
- Using reverse proxy (nginx and Apache)
- Run JupyterHub without root privileges using `sudo`

View File

@@ -0,0 +1,82 @@
# Configure GitHub OAuth
In this example, we show a configuration file for a fairly standard JupyterHub
deployment with the following assumptions:
* Running JupyterHub on a single cloud server
* Using SSL on the standard HTTPS port 443
* Using GitHub OAuth (using oauthenticator) for login
* Using the default spawner (to configure other spawners, uncomment and edit
`spawner_class` as well as follow the instructions for your desired spawner)
* Users exist locally on the server
* 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`.
The `jupyterhub_config.py` file would have these settings:
```python
# jupyterhub_config.py file
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)
# Allows multiple single-server per user
c.JupyterHub.allow_named_servers = True
# 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
# 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'}
# uses the default spawner
# To use a different spawner, uncomment `spawner_class` and set to desired
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
# configuration.
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/jupyter/jupyter_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
environment variable to be set prior to launching JupyterHub:
```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
# append log output to log file /var/log/jupyterhub.log
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
```

View File

@@ -0,0 +1,190 @@
# Using a reverse proxy
In the following example, we show configuration files for a JupyterHub server
running locally on port `8000` but accessible from the outside on the standard
SSL port `443`. This could be useful if the JupyterHub server machine is also
hosting other domains or content on `443`. The goal in this example is to
satisfy the following:
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
also on port `443`
* `nginx` or `apache` is used as the public access point (which means that
only nginx/apache will bind to `443`)
* After testing, the server in question should be able to score at least an A on the
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
```python
# Force the proxy to only listen to connections to 127.0.0.1
c.JupyterHub.ip = '127.0.0.1'
```
For high-quality SSL configuration, we also generate Diffie-Helman parameters.
This can take a few minutes:
```bash
openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
```
## nginx
The **`nginx` server config file** is fairly standard fare except for the two
`location` blocks within the `HUB.DOMAIN.TLD` config file:
```bash
# top-level http config for websocket headers
# If Upgrade is defined, Connection = upgrade
# If Upgrade is empty, Connection = close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP server to redirect all 80 traffic to SSL/HTTPS
server {
listen 80;
server_name HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
# HTTPS server to handle JupyterHub
server {
listen 443;
ssl on;
server_name HUB.DOMAIN.TLD;
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security max-age=15768000;
# Managing literal requests to the JupyterHub front end
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# websocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
If `nginx` is not running on port 443, substitute `$http_host` for `$host` on
the lines setting the `Host` header.
`nginx` will now be the front facing element of JupyterHub on `443` which means
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
on the same machine and network interface. In fact, one can simply use the same
server blocks as above for `NO_HUB` and simply add line for the root directory
of the site as well as the applicable location call:
```bash
server {
listen 80;
server_name NO_HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
server {
listen 443;
ssl on;
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
# SSL cert may differ
# Set the appropriate root directory
root /var/www/html
# Set URI handling
location / {
try_files $uri $uri/ =404;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
Now restart `nginx`, restart the JupyterHub, and enjoy accessing
`https://HUB.DOMAIN.TLD` while serving other content securely on
`https://NO_HUB.DOMAIN.TLD`.
## Apache
As with nginx above, you can use [Apache](https://httpd.apache.org) as the reverse proxy.
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
- Redirect HTTP to HTTPS
- Good SSL Configuration
- Support for websockets on any proxied URL
- JupyterHub is running locally at http://127.0.0.1:8000
```bash
# redirect HTTP to HTTPS
Listen 80
<VirtualHost HUB.DOMAIN.TLD:80>
ServerName HUB.DOMAIN.TLD
Redirect / https://HUB.DOMAIN.TLD/
</VirtualHost>
Listen 443
<VirtualHost HUB.DOMAIN.TLD:443>
ServerName HUB.DOMAIN.TLD
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
RewriteCond %{HTTP:Connection} Upgrade [NC]
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:8000/$1 [P,L]
<Location "/">
# preserve Host header to avoid cross-origin problems
ProxyPreserveHost on
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
</Location>
</VirtualHost>
```

View File

@@ -0,0 +1,254 @@
# Run JupyterHub without root privileges using `sudo`
**Note:** Setting up `sudo` permissions involves many pieces of system
configuration. It is quite easy to get wrong and very difficult to debug.
Only do this if you are very sure you must.
## Overview
There are many Authenticators and Spawners available for JupyterHub. Some, such
as DockerSpawner or OAuthenticator, do not need any elevated permissions. This
document describes how to get the full default behavior of JupyterHub while
running notebook servers as real system users on a shared system without
running the Hub itself as root.
Since JupyterHub needs to spawn processes as other users, the simplest way
is to run it as root, spawning user servers with [setuid](http://linux.die.net/man/2/setuid).
But this isn't especially safe, because you have a process running on the
public web as root.
A **more prudent way** to run the server while preserving functionality is to
create a dedicated user with `sudo` access restricted to launching and
monitoring single-user servers.
## Create a user
To do this, first create a user that will run the Hub:
```bash
sudo useradd rhea
```
This user shouldn't have a login shell or password (possible with -r).
## Set up sudospawner
Next, you will need [sudospawner](https://github.com/jupyter/sudospawner)
to enable monitoring the single-user servers with sudo:
```bash
sudo pip install sudospawner
```
Now we have to configure sudo to allow the Hub user (`rhea`) to launch
the sudospawner script on behalf of our hub users (here `zoe` and `wash`).
We want to confine these permissions to only what we really need.
## Edit `/etc/sudoers`
To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers):
- specify the list of users `JUPYTER_USERS` for whom `rhea` can spawn servers
- set the command `JUPYTER_CMD` that `rhea` can execute on behalf of users
- give `rhea` permission to run `JUPYTER_CMD` on behalf of `JUPYTER_USERS`
without entering a password
For example:
```bash
# comma-separated whitelist of users that can spawn single-user servers
# this should include all of your Hub users
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
# the command(s) the Hub can run on behalf of the above users without needing a password
# the exact path may differ, depending on how sudospawner was installed
Cmnd_Alias JUPYTER_CMD = /usr/local/bin/sudospawner
# actually give the Hub user permission to run the above command on behalf
# of the above users without prompting for a password
rhea ALL=(JUPYTER_USERS) NOPASSWD:JUPYTER_CMD
```
It might be useful to modifiy `secure_path` to add commands in path.
As an alternative to adding every user to the `/etc/sudoers` file, you can
use a group in the last line above, instead of `JUPYTER_USERS`:
```bash
rhea ALL=(%jupyterhub) NOPASSWD:JUPYTER_CMD
```
If the `jupyterhub` group exists, there will be no need to edit `/etc/sudoers`
again. A new user will gain access to the application when added to the group:
```bash
$ adduser -G jupyterhub newuser
```
## Test `sudo` setup
Test that the new user doesn't need to enter a password to run the sudospawner
command.
This should prompt for your password to switch to rhea, but *not* prompt for
any password for the second switch. It should show some help output about
logging options:
```bash
$ sudo -u rhea sudo -n -u $USER /usr/local/bin/sudospawner --help
Usage: /usr/local/bin/sudospawner [OPTIONS]
Options:
--help show this help information
...
```
And this should fail:
```bash
$ sudo -u rhea sudo -n -u $USER echo 'fail'
sudo: a password is required
```
## Enable PAM for non-root
By default, [PAM authentication](http://en.wikipedia.org/wiki/Pluggable_authentication_module)
is used by JupyterHub. To use PAM, the process may need to be able to read
the shadow password database.
### Shadow group (Linux)
```bash
$ ls -l /etc/shadow
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow
```
If there's already a shadow group, you are set. If its permissions are more like:
```bash
$ ls -l /etc/shadow
-rw------- 1 root wheel 2197 Jul 21 13:41 shadow
```
Then you may want to add a shadow group, and make the shadow file group-readable:
```bash
$ sudo groupadd shadow
$ sudo chgrp shadow /etc/shadow
$ sudo chmod g+r /etc/shadow
```
We want our new user to be able to read the shadow passwords, so add it to the shadow group:
```bash
$ sudo usermod -a -G shadow rhea
```
If you want jupyterhub to serve pages on a restricted port (such as port 80 for http),
then you will need to give `node` permission to do so:
```bash
sudo setcap 'cap_net_bind_service=+ep' /usr/bin/node
```
However, you may want to further understand the consequences of this.
You may also be interested in limiting the amount of CPU any process can use
on your server. `cpulimit` is a useful tool that is available for many Linux
distributions' packaging system. This can be used to keep any user's process
from using too much CPU cycles. You can configure it accoring to [these
instructions](http://ubuntuforums.org/showthread.php?t=992706).
### Shadow group (FreeBSD)
**NOTE:** This has not been tested and may not work as expected.
```bash
$ ls -l /etc/spwd.db /etc/master.passwd
-rw------- 1 root wheel 2516 Aug 22 13:35 /etc/master.passwd
-rw------- 1 root wheel 40960 Aug 22 13:35 /etc/spwd.db
```
Add a shadow group if there isn't one, and make the shadow file group-readable:
```bash
$ sudo pw group add shadow
$ sudo chgrp shadow /etc/spwd.db
$ sudo chmod g+r /etc/spwd.db
$ sudo chgrp shadow /etc/master.passwd
$ sudo chmod g+r /etc/master.passwd
```
We want our new user to be able to read the shadow passwords, so add it to the
shadow group:
```bash
$ sudo pw user mod rhea -G shadow
```
## Test that PAM works
We can verify that PAM is working, with:
```bash
$ sudo -u rhea python3 -c "import pamela, getpass; print(pamela.authenticate('$USER', getpass.getpass()))"
Password: [enter your unix password]
```
## Make a directory for JupyterHub
JupyterHub stores its state in a database, so it needs write access to a directory.
The simplest way to deal with this is to make a directory owned by your Hub user,
and use that as the CWD when launching the server.
```bash
$ sudo mkdir /etc/jupyterhub
$ sudo chown rhea /etc/jupyterhub
```
## Start jupyterhub
Finally, start the server as our newly configured user, `rhea`:
```bash
$ cd /etc/jupyterhub
$ sudo -u rhea jupyterhub --JupyterHub.spawner_class=sudospawner.SudoSpawner
```
And try logging in.
### Troubleshooting: SELinux
If you still get a generic `Permission denied` `PermissionError`, it's possible SELinux is blocking you.
Here's how you can make a module to allow this.
First, put this in a file sudo_exec_selinux.te:
```bash
module sudo_exec 1.1;
require {
type unconfined_t;
type sudo_exec_t;
class file { read entrypoint };
}
#============= unconfined_t ==============
allow unconfined_t sudo_exec_t:file entrypoint;
```
Then run all of these commands as root:
```bash
$ checkmodule -M -m -o sudo_exec_selinux.mod sudo_exec_selinux.te
$ semodule_package -o sudo_exec_selinux.pp -m sudo_exec_selinux.mod
$ semodule -i sudo_exec_selinux.pp
```
### Troubleshooting: PAM session errors
If the PAM authentication doesn't work and you see errors for
`login:session-auth`, or similar, considering updating to `master`
and/or incorporating this commit https://github.com/jupyter/jupyterhub/commit/40368b8f555f04ffdd662ffe99d32392a088b1d2
and configuration option, `c.PAMAuthenticator.open_sessions = False`.

View File

@@ -14,3 +14,6 @@ Technical Reference
upgrading
templates
config-examples
config-ghoauth
config-proxy
config-sudo

View File

@@ -1,22 +1,26 @@
# Writing a custom Proxy implementation
JupyterHub 0.8 introduced the ability to write a custom implementation of the proxy.
This enables deployments with different needs than the default proxy,
configurable-http-proxy (CHP).
CHP is a single-process nodejs proxy that they Hub manages by default as a subprocess
(it can be run externally, as well, and typically is in production deployments).
JupyterHub 0.8 introduced the ability to write a custom implementation of the
proxy. This enables deployments with different needs than the default proxy,
configurable-http-proxy (CHP). CHP is a single-process nodejs proxy that they
Hub manages by default as a subprocess (it can be run externally, as well, and
typically is in production deployments).
The upside to CHP, and why we use it by default, is that it's easy to install and run (if you have nodejs, you are set!).
The downsides are that it's a single process and does not support any persistence of the routing table.
So if the proxy process dies, your whole JupyterHub instance is inaccessible until the Hub notices, restarts the proxy, and restores the routing table.
For deployments that want to avoid such a single point of failure,
or leverage existing proxy infrastructure in their chosen deployment (such as Kubernetes ingress objects),
the Proxy API provides a way to do that.
The upside to CHP, and why we use it by default, is that it's easy to install
and run (if you have nodejs, you are set!). The downsides are that it's a
single process and does not support any persistence of the routing table. So
if the proxy process dies, your whole JupyterHub instance is inaccessible
until the Hub notices, restarts the proxy, and restores the routing table. For
deployments that want to avoid such a single point of failure, or leverage
existing proxy infrastructure in their chosen deployment (such as Kubernetes
ingress objects), the Proxy API provides a way to do that.
In general, for a proxy to be usable by JupyterHub, it must:
1. support websockets without prior knowledge of the URL where websockets may occur
2. support trie-based routing (i.e. allow different routes on `/foo` and `/foo/bar` and route based on specificity)
1. support websockets without prior knowledge of the URL where websockets may
occur
2. support trie-based routing (i.e. allow different routes on `/foo` and
`/foo/bar` and route based on specificity)
3. adding or removing a route should not cause existing connections to drop
Optionally, if the JupyterHub deployment is to use host-based routing,
@@ -35,10 +39,10 @@ class MyProxy(Proxy):
...
```
## Starting and stopping the proxy
If your proxy should be launched when the Hub starts, you must define how to start and stop your proxy:
If your proxy should be launched when the Hub starts, you must define how
to start and stop your proxy:
```python
from tornado import gen
@@ -55,8 +59,8 @@ class MyProxy(Proxy):
These methods **may** be coroutines.
`c.Proxy.should_start` is a configurable flag that determines whether the Hub should call these methods when the Hub itself starts and stops.
`c.Proxy.should_start` is a configurable flag that determines whether the
Hub should call these methods when the Hub itself starts and stops.
### Purely external proxies
@@ -70,31 +74,30 @@ class MyProxy(Proxy):
should_start = False
```
## Routes
## Adding and removing routes
At its most basic, a Proxy implementation defines a mechanism to add, remove, and retrieve routes.
A proxy that implements these three methods is complete.
At its most basic, a Proxy implementation defines a mechanism to add, remove,
and retrieve routes. A proxy that implements these three methods is complete.
Each of these methods **may** be a coroutine.
**Definition:** routespec
A routespec, which will appear in these methods, is a string describing a route to be proxied,
such as `/user/name/`. A routespec will:
A routespec, which will appear in these methods, is a string describing a
route to be proxied, such as `/user/name/`. A routespec will:
1. always end with `/`
2. always start with `/` if it is a path-based route `/proxy/path/`
3. precede the leading `/` with a host for host-based routing, e.g. `host.tld/proxy/path/`
3. precede the leading `/` with a host for host-based routing, e.g.
`host.tld/proxy/path/`
### Adding a route
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data` argument
that should be attacked to the proxy route.
When that route is retrieved, the `data` argument should be returned as well.
If your proxy implementation doesn't support storing data attached to routes,
then your Python wrapper may have to handle storing the `data` piece itself,
e.g in a simple file or database.
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data`
argument that should be attacked to the proxy route. When that route is
retrieved, the `data` argument should be returned as well. If your proxy
implementation doesn't support storing data attached to routes, then your
Python wrapper may have to handle storing the `data` piece itself, e.g in a
simple file or database.
```python
@gen.coroutine
@@ -113,12 +116,10 @@ proxy.add_route('/user/pgeorgiou/', 'http://127.0.0.1:1227',
{'user': 'pgeorgiou'})
```
### Removing routes
`delete_route()` is given a routespec to delete.
If there is no such route, `delete_route` should still succeed,
but a warning may be issued.
`delete_route()` is given a routespec to delete. If there is no such route,
`delete_route` should still succeed, but a warning may be issued.
```python
@gen.coroutine
@@ -126,18 +127,17 @@ def delete_route(self, routespec):
"""Delete the route"""
```
### Retrieving routes
For retrieval, you only *need* to implement a single method that retrieves all routes.
The return value for this function should be a dictionary, keyed by `routespect`,
of dicts whose keys are the same three arguments passed to `add_route`
(`routespec`, `target`, `data`)
For retrieval, you only *need* to implement a single method that retrieves all
routes. The return value for this function should be a dictionary, keyed by
`routespect`, of dicts whose keys are the same three arguments passed to
`add_route` (`routespec`, `target`, `data`)
```python
@gen.coroutine
def get_all_routes(self):
"""Return all routes, keyed by routespec""""
"""Return all routes, keyed by routespec"""
```
```python
@@ -150,15 +150,15 @@ def get_all_routes(self):
}
```
## Note on activity tracking
#### Note on activity tracking
JupyterHub can track activity of users, for use in services such as culling idle servers.
As of JupyterHub 0.8, this activity tracking is the responsibility of the proxy.
If your proxy implementation can track activity to endpoints,
it may add a `last_activity` key to the `data` of routes retrieved in `.get_all_routes()`.
If present, the value of `last_activity` should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date string:
JupyterHub can track activity of users, for use in services such as culling
idle servers. As of JupyterHub 0.8, this activity tracking is the
responsibility of the proxy. If your proxy implementation can track activity
to endpoints, it may add a `last_activity` key to the `data` of routes
retrieved in `.get_all_routes()`. If present, the value of `last_activity`
should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date
string:
```python
{
@@ -173,11 +173,9 @@ If present, the value of `last_activity` should be an [ISO8601](https://en.wikip
}
```
If the proxy does not track activity, then only activity to the Hub itself is
tracked, and services such as cull-idle will not work.
If the proxy does not track activity, then only activity to the Hub itself is tracked,
and services such as cull-idle will not work.
Now that `notebook-5.0` tracks activity internally,
we can retrieve activity information from the single-user servers instead,
removing the need to track activity in the proxy.
But this is not yet implemented in JupyterHub 0.8.0.
Now that `notebook-5.0` tracks activity internally, we can retrieve activity
information from the single-user servers instead, removing the need to track
activity in the proxy. But this is not yet implemented in JupyterHub 0.8.0.

View File

@@ -1,28 +1,55 @@
# Templates
# Working with templates and UI
The pages of the JupyterHub application are generated from [Jinja](http://jinja.pocoo.org/) templates. These allow the header, for example, to be defined once and incorporated into all pages. By providing your own templates, you can have complete control over JupyterHub's appearance.
The pages of the JupyterHub application are generated from
[Jinja](http://jinja.pocoo.org/) templates. These allow the header, for
example, to be defined once and incorporated into all pages. By providing
your own templates, you can have complete control over JupyterHub's
appearance.
## Custom Templates
JupyterHub will look for custom templates in all of the paths in the `JupyterHub.template_paths` configuration option, falling back on the [default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) if no custom template with that name is found. (This fallback behavior is new in version 0.9; previous versions searched only those paths explicitly included in `template_paths`.) This means you can override as many or as few templates as you desire.
JupyterHub will look for custom templates in all of the paths in the
`JupyterHub.template_paths` configuration option, falling back on the
[default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
if no custom template with that name is found. This fallback
behavior is new in version 0.9; previous versions searched only those paths
explicitly included in `template_paths`. You may override as many
or as few templates as you desire.
## Extending Templates
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance). A base template can define a `block`, and child templates can replace or supplement the material in the block. The [JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) make extensive use of this feature, which allows you to customize parts of the interface easily.
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance).
A base template can define a `block`, and child templates can replace or
supplement the material in the block. The
[JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
make extensive use of blocks, which allows you to customize parts of the
interface easily.
In general, a child template can extend a base template, `base.html`, by beginning with
```
In general, a child template can extend a base template, `base.html`, by beginning with:
```html
{% extends "base.html" %}
```
This works, unless you are trying to extend the default template for the same file name. Starting in version 0.9, you may refer to the base file with a `templates/` prefix. Thus, if you are writing a custom `base.html`, start it with
```
This works, unless you are trying to extend the default template for the same
file name. Starting in version 0.9, you may refer to the base file with a
`templates/` prefix. Thus, if you are writing a custom `base.html`, start the
file with this block:
```html
{% extends "templates/base.html" %}
```
By defining `block`s with same name as in the base template, child templates can replace those sections with custom content. The content from the base template can be included with the `{{ super() }}` directive.
By defining `block`s with same name as in the base template, child templates
can replace those sections with custom content. The content from the base
template can be included with the `{{ super() }}` directive.
### Example
To add an additional message to the spawn-pending page, below the existing text about the server starting up, place this content in a file named `spawn_pending.html` in a directory included in the `JupyterHub.template_paths` configuration option.
To add an additional message to the spawn-pending page, below the existing
text about the server starting up, place this content in a file named
`spawn_pending.html` in a directory included in the
`JupyterHub.template_paths` configuration option.
```html
{% extends "templates/spawn_pending.html" %}

View File

@@ -1,4 +1,4 @@
.. upgrade-dot-eight:
.. _upgrade-dot-eight:
Upgrading to JupyterHub version 0.8
===================================

View File

@@ -5,7 +5,7 @@ for external services that may not be otherwise integrated with JupyterHub.
The main feature this enables is using JupyterHub like a 'regular' OAuth 2
provider for services running anywhere.
There are two examples here. `whoami-oauth` uses `jupyterhub.services.HubOAuthenticated`
There are two examples here. `whoami-oauth` (in the service-whoami directory) uses `jupyterhub.services.HubOAuthenticated`
to authenticate requests with the Hub for a service run on its own host.
This is an implementation of OAuth 2.0 provided by the jupyterhub package,
which configures all of the necessary URLs from environment variables.

View File

@@ -18,4 +18,4 @@ export JUPYTERHUB_OAUTH_CALLBACK_URL="$JUPYTERHUB_SERVICE_URL/oauth_callback"
export JUPYTERHUB_HOST='http://127.0.0.1:8000'
# launch the service
exec python3 whoami-oauth.py
exec python3 ../service-whoami/whoami-oauth.py

View File

@@ -1,46 +0,0 @@
"""An example service authenticating with the Hub.
This example service serves `/services/whoami/`,
authenticated with the Hub,
showing the user their own info.
"""
from getpass import getuser
import json
import os
from urllib.parse import urlparse
from tornado.ioloop import IOLoop
from tornado import log
from tornado.httpserver import HTTPServer
from tornado.web import RequestHandler, Application, authenticated
from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access this service
@authenticated
def get(self):
user_model = self.get_current_user()
self.set_header('content-type', 'application/json')
self.write(json.dumps(user_model, indent=1, sort_keys=True))
def main():
log.enable_pretty_logging()
app = Application([
(os.environ['JUPYTERHUB_SERVICE_PREFIX'], WhoAmIHandler),
(url_path_join(os.environ['JUPYTERHUB_SERVICE_PREFIX'], 'oauth_callback'), HubOAuthCallbackHandler),
(r'.*', WhoAmIHandler),
], cookie_secret=os.urandom(32))
http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
log.app_log.info("Running whoami service on %s", os.environ['JUPYTERHUB_SERVICE_URL'])
http_server.listen(url.port, url.hostname)
IOLoop.current().start()
if __name__ == '__main__':
main()

View File

@@ -26,6 +26,10 @@ After logging in with your local-system credentials, you should see a JSON dump
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
You may set the `hub_users` configuration in the service script
to restrict access to the service to a whitelist of allowed users.
By default, any authenticated user is allowed.
A similar service could be run externally, by setting the JupyterHub service environment variables:
JUPYTERHUB_API_TOKEN

View File

@@ -17,7 +17,11 @@ from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access this service
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# hub_users = {getuser()}
@authenticated
def get(self):

View File

@@ -15,7 +15,11 @@ from jupyterhub.services.auth import HubAuthenticated
class WhoAmIHandler(HubAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access me
# hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# hub_users = {getuser()}
@authenticated
def get(self):
@@ -37,4 +41,4 @@ def main():
IOLoop.current().start()
if __name__ == '__main__':
main()
main()

View File

@@ -7,10 +7,17 @@ version_info = (
0,
9,
0,
'b1',
"b3", # release (b1, rc1)
# "dev", # dev
)
__version__ = '.'.join(map(str, version_info))
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1
# 0.1.0a1
# 0.1.0b1.dev
# 0.1.0.dev
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
def _check_version(hub_version, singleuser_version, log):

View File

@@ -0,0 +1,24 @@
"""Add APIToken.expires_at
Revision ID: 896818069c98
Revises: d68c98b66cd4
Create Date: 2018-05-07 11:35:58.050542
"""
# revision identifiers, used by Alembic.
revision = '896818069c98'
down_revision = 'd68c98b66cd4'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('api_tokens', sa.Column('expires_at', sa.DateTime(), nullable=True))
def downgrade():
op.drop_column('api_tokens', 'expires_at')

View File

@@ -6,6 +6,7 @@ import json
from http.client import responses
from sqlalchemy.exc import SQLAlchemyError
from tornado import web
from .. import orm
@@ -87,6 +88,10 @@ class APIHandler(BaseHandler):
if reason:
status_message = reason
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
self.set_header('Content-Type', 'application/json')
# allow setting headers from exceptions
# since exception handler clears headers
@@ -115,16 +120,20 @@ class APIHandler(BaseHandler):
def token_model(self, token):
"""Get the JSON model for an APIToken"""
expires_at = None
if isinstance(token, orm.APIToken):
kind = 'api_token'
extra = {
'note': token.note,
}
expires_at = token.expires_at
elif isinstance(token, orm.OAuthAccessToken):
kind = 'oauth'
extra = {
'oauth_client': token.client.description or token.client.client_id,
}
if token.expires_at:
expires_at = datetime.fromtimestamp(token.expires_at)
else:
raise TypeError(
"token must be an APIToken or OAuthAccessToken, not %s"

View File

@@ -4,6 +4,7 @@
# Distributed under the terms of the Modified BSD License.
import asyncio
from datetime import datetime
import json
from async_generator import aclosing
@@ -201,13 +202,30 @@ class UserTokenListAPIHandler(APIHandler):
user = self.find_user(name)
if not user:
raise web.HTTPError(404, "No such user: %s" % name)
now = datetime.utcnow()
api_tokens = []
def sort_key(token):
return token.last_activity or token.created
for token in sorted(user.api_tokens, key=sort_key):
if token.expires_at and token.expires_at < now:
# exclude expired tokens
self.db.delete(token)
self.db.commit()
continue
api_tokens.append(self.token_model(token))
oauth_tokens = []
# OAuth tokens use integer timestamps
now_timestamp = now.timestamp()
for token in sorted(user.oauth_tokens, key=sort_key):
if token.expires_at and token.expires_at < now_timestamp:
# exclude expired tokens
self.db.delete(token)
self.db.commit()
continue
oauth_tokens.append(self.token_model(token))
self.write(json.dumps({
'api_tokens': api_tokens,
@@ -252,7 +270,7 @@ class UserTokenListAPIHandler(APIHandler):
if requester is not user:
note += " by %s %s" % (kind, requester.name)
api_token = user.new_api_token(note=note)
api_token = user.new_api_token(note=note, expires_in=body.get('expires_in', None))
if requester is not user:
self.log.info("%s %s requested API token for %s", kind.title(), requester.name, user.name)
else:

View File

@@ -9,6 +9,7 @@ import atexit
import binascii
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from functools import partial
from getpass import getuser
import logging
from operator import itemgetter
@@ -26,7 +27,7 @@ if sys.version_info[:2] < (3, 3):
from dateutil.parser import parse as parse_date
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from tornado.httpclient import AsyncHTTPClient
import tornado.httpserver
@@ -286,6 +287,10 @@ class JupyterHub(Application):
def _template_paths_default(self):
return [os.path.join(self.data_files_path, 'templates')]
template_vars = Dict(
help="Extra variables to be passed into jinja templates",
).tag(config=True)
confirm_no_ssl = Bool(False,
help="""DEPRECATED: does nothing"""
).tag(config=True)
@@ -310,6 +315,7 @@ class JupyterHub(Application):
should be accessed by users.
.. deprecated: 0.9
Use JupyterHub.bind_url
"""
).tag(config=True)
@@ -325,26 +331,6 @@ class JupyterHub(Application):
"""
).tag(config=True)
@observe('ip', 'port')
def _ip_port_changed(self, change):
urlinfo = urlparse(self.bind_url)
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
self.bind_url = urlunparse(urlinfo)
bind_url = Unicode(
"http://127.0.0.1:8000",
help="""The public facing URL of the whole JupyterHub application.
This is the address on which the proxy will bind.
Sets protocol, ip, base_url
"""
).tag(config=True)
@observe('bind_url')
def _bind_url_changed(self, change):
urlinfo = urlparse(change.new)
self.base_url = urlinfo.path
base_url = URLPrefix('/',
help="""The base URL of the entire application.
@@ -361,6 +347,25 @@ class JupyterHub(Application):
# call validate to ensure leading/trailing slashes
return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
@observe('ip', 'port', 'base_url')
def _url_part_changed(self, change):
"""propagate deprecated ip/port/base_url config to the bind_url"""
urlinfo = urlparse(self.bind_url)
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
urlinfo = urlinfo._replace(path=self.base_url)
bind_url = urlunparse(urlinfo)
if bind_url != self.bind_url:
self.bind_url = bind_url
bind_url = Unicode(
"http://:8000",
help="""The public facing URL of the whole JupyterHub application.
This is the address on which the proxy will bind.
Sets protocol, ip, base_url
"""
).tag(config=True)
subdomain_host = Unicode('',
help="""Run single-user servers on subdomains of this host.
@@ -932,6 +937,24 @@ class JupyterHub(Application):
handlers[i] = tuple(lis)
return handlers
extra_handlers = List(
help="""
Register extra tornado Handlers for jupyterhub.
Should be of the form ``("<regex>", Handler)``
The Hub prefix will be added, so `/my-page` will be served at `/hub/my-page`.
""",
).tag(config=True)
default_url = Unicode(
help="""
The default URL for users when they arrive (e.g. when user directs to "/")
By default, redirects users to their own server.
""",
).tag(config=True)
def init_handlers(self):
h = []
# load handlers from the authenticator
@@ -940,6 +963,9 @@ class JupyterHub(Application):
h.extend(handlers.default_handlers)
h.extend(apihandlers.default_handlers)
# add any user configurable handlers.
h.extend(self.extra_handlers)
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
self.handlers = self.add_url_prefix(self.hub_prefix, h)
# some extra handlers, outside hub_prefix
@@ -1264,10 +1290,23 @@ class JupyterHub(Application):
self.log.debug("Not duplicating token %s", orm_token)
db.commit()
# purge expired tokens hourly
purge_expired_tokens_interval = 3600
async def init_api_tokens(self):
"""Load predefined API tokens (for services) into database"""
await self._add_tokens(self.service_tokens, kind='service')
await self._add_tokens(self.api_tokens, kind='user')
purge_expired_tokens = partial(orm.APIToken.purge_expired, self.db)
purge_expired_tokens()
# purge expired tokens hourly
# we don't need to be prompt about this
# because expired tokens cannot be used anyway
pc = PeriodicCallback(
purge_expired_tokens,
1e3 * self.purge_expired_tokens_interval,
)
pc.start()
def init_services(self):
self._service_map.clear()
@@ -1454,10 +1493,16 @@ class JupyterHub(Application):
oauth_client_ids.add(spawner.oauth_client_id)
client_store = self.oauth_provider.client_authenticator.client_store
for oauth_client in self.db.query(orm.OAuthClient):
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids:
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
self.db.delete(oauth_client)
# Some deployments that create temporary users may have left *lots*
# of entries here.
# Don't try to delete them all in one transaction,
# commit at most 100 deletions at a time.
if i % 100 == 0:
self.db.commit()
self.db.commit()
def init_proxy(self):
@@ -1517,6 +1562,7 @@ class JupyterHub(Application):
authenticator=self.authenticator,
spawner_class=self.spawner_class,
base_url=self.base_url,
default_url=self.default_url,
cookie_secret=self.cookie_secret,
cookie_max_age_days=self.cookie_max_age_days,
redirect_to_server=self.redirect_to_server,
@@ -1526,6 +1572,7 @@ class JupyterHub(Application):
static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
static_handler_class=CacheControlStaticFilesHandler,
template_path=self.template_paths,
template_vars=self.template_vars,
jinja2_env=jinja_env,
version_hash=version_hash,
subdomain_host=self.subdomain_host,
@@ -1580,6 +1627,33 @@ class JupyterHub(Application):
cfg.JupyterHub.merge(cfg.JupyterHubApp)
self.update_config(cfg)
self.write_pid_file()
def _log_cls(name, cls):
"""Log a configured class
Logs the class and version (if found) of Authenticator
and Spawner
"""
# try to guess the version from the top-level module
# this will work often enough to be useful.
# no need to be perfect.
if cls.__module__:
mod = sys.modules.get(cls.__module__.split('.')[0])
version = getattr(mod, '__version__', '')
if version:
version = '-{}'.format(version)
else:
version = ''
self.log.info(
"Using %s: %s.%s%s",
name,
cls.__module__ or '',
cls.__name__,
version,
)
_log_cls("Authenticator", self.authenticator_class)
_log_cls("Spawner", self.spawner_class)
self.init_pycurl()
self.init_secrets()
self.init_db()
@@ -1718,7 +1792,13 @@ class JupyterHub(Application):
self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count)
self.db.commit()
try:
self.db.commit()
except SQLAlchemyError:
self.log.exception("Rolling back session due to database error")
self.db.rollback()
return
await self.proxy.check_routes(self.users, self._service_map, routes)
async def start(self):

View File

@@ -49,7 +49,7 @@ class Authenticator(LoggingConfigurable):
Encrypting auth_state requires the cryptography package.
Additionally, the JUPYTERHUB_CRYPTO_KEY envirionment variable must
Additionally, the JUPYTERHUB_CRYPT_KEY environment variable must
contain one (or more, separated by ;) 32B encryption keys.
These can be either base64 or hex-encoded.

View File

@@ -15,6 +15,7 @@ import uuid
from jinja2 import TemplateNotFound
from sqlalchemy.exc import SQLAlchemyError
from tornado.log import app_log
from tornado.httputil import url_concat, HTTPHeaders
from tornado.ioloop import IOLoop
@@ -61,6 +62,10 @@ class BaseHandler(RequestHandler):
def base_url(self):
return self.settings.get('base_url', '/')
@property
def default_url(self):
return self.settings.get('default_url', '')
@property
def version_hash(self):
return self.settings.get('version_hash', '')
@@ -260,10 +265,17 @@ class BaseHandler(RequestHandler):
def get_current_user(self):
"""get current username"""
user = self.get_current_user_token()
if user is not None:
return user
return self.get_current_user_cookie()
if not hasattr(self, '_jupyterhub_user'):
try:
user = self.get_current_user_token()
if user is None:
user = self.get_current_user_cookie()
self._jupyterhub_user = user
except Exception:
# don't let errors here raise more than once
self._jupyterhub_user = None
raise
return self._jupyterhub_user
def find_user(self, name):
"""Get a user by name
@@ -413,10 +425,20 @@ class BaseHandler(RequestHandler):
- else: /hub/home
"""
next_url = self.get_argument('next', default='')
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
if (next_url + '/').startswith(
(
'%s://%s/' % (self.request.protocol, self.request.host),
'//%s/' % self.request.host,
)
):
# treat absolute URLs for our host as absolute paths:
next_url = urlparse(next_url).path
if next_url and not next_url.startswith('/'):
parsed = urlparse(next_url)
next_url = parsed.path
if parsed.query:
next_url = next_url + '?' + parsed.query
if parsed.hash:
next_url = next_url + '#' + parsed.hash
if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = ''
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
@@ -429,9 +451,14 @@ class BaseHandler(RequestHandler):
self.request.uri, next_url,
)
if not next_url:
# custom default URL
next_url = self.default_url
if not next_url:
# default URL after login
# if self.redirect_to_server, default login URL initiates spawn
# if self.redirect_to_server, default login URL initiates spawn,
# otherwise send to Hub home page (control panel)
if user and self.redirect_to_server:
next_url = user.url
else:
@@ -752,7 +779,7 @@ class BaseHandler(RequestHandler):
@property
def template_namespace(self):
user = self.get_current_user()
return dict(
ns = dict(
base_url=self.hub.base_url,
prefix=self.base_url,
user=user,
@@ -762,6 +789,9 @@ class BaseHandler(RequestHandler):
static_url=self.static_url,
version_hash=self.version_hash,
)
if self.settings['template_vars']:
ns.update(self.settings['template_vars'])
return ns
def write_error(self, status_code, **kwargs):
"""render custom error pages"""
@@ -782,6 +812,10 @@ class BaseHandler(RequestHandler):
if reason:
message = reasons.get(reason, reason)
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
# build template namespace
ns = dict(
status_code=status_code,

View File

@@ -3,6 +3,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from collections import defaultdict
from datetime import datetime
from http.client import responses
@@ -30,7 +31,9 @@ class RootHandler(BaseHandler):
"""
def get(self):
user = self.get_current_user()
if user:
if self.default_url:
url = self.default_url
elif user:
url = self.get_next_url(user)
else:
url = self.settings['login_url']
@@ -229,12 +232,24 @@ class TokenPageHandler(BaseHandler):
token.last_activity or never,
token.created or never,
)
api_tokens = sorted(user.api_tokens, key=sort_key, reverse=True)
now = datetime.utcnow()
api_tokens = []
for token in sorted(user.api_tokens, key=sort_key, reverse=True):
if token.expires_at and token.expires_at < now:
self.db.delete(token)
self.db.commit()
continue
api_tokens.append(token)
# group oauth client tokens by client id
from collections import defaultdict
oauth_tokens = defaultdict(list)
for token in user.oauth_tokens:
if token.expires_at and token.expires_at < now:
self.log.warning("Deleting expired token")
self.db.delete(token)
self.db.commit()
continue
if not token.client_id:
# token should have been deleted when client was deleted
self.log.warning("Deleting stale oauth token for %s", user.name)
@@ -260,7 +275,7 @@ class TokenPageHandler(BaseHandler):
token = tokens[0]
oauth_clients.append({
'client': token.client,
'description': token.client.description or token.client.client_id,
'description': token.client.description or token.client.identifier,
'created': created,
'last_activity': last_activity,
'tokens': tokens,

View File

@@ -3,7 +3,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from datetime import datetime
from datetime import datetime, timedelta
import enum
import json
@@ -14,7 +14,7 @@ from tornado.log import app_log
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
from sqlalchemy import (
create_engine, event, inspect,
create_engine, event, exc, inspect, or_, select,
Column, Integer, ForeignKey, Unicode, Boolean,
DateTime, Enum, Table,
)
@@ -33,6 +33,9 @@ from .utils import (
new_token, hash_token, compare_token,
)
# top-level variable for easier mocking in tests
utcnow = datetime.utcnow
class JSONDict(TypeDecorator):
"""Represents an immutable structure as a json-encoded string.
@@ -176,12 +179,12 @@ class User(Base):
running=sum(bool(s.server) for s in self._orm_spawners),
)
def new_api_token(self, token=None, generated=True, note=''):
def new_api_token(self, token=None, **kwargs):
"""Create a new API token
If `token` is given, load that token.
"""
return APIToken.new(token=token, user=self, note=note, generated=generated)
return APIToken.new(token=token, user=self, **kwargs)
@classmethod
def find(cls, db, name):
@@ -242,11 +245,11 @@ class Service(Base):
server = relationship(Server, cascade='all')
pid = Column(Integer)
def new_api_token(self, token=None, generated=True, note=''):
def new_api_token(self, token=None, **kwargs):
"""Create a new API token
If `token` is given, load that token.
"""
return APIToken.new(token=token, service=self, note=note, generated=generated)
return APIToken.new(token=token, service=self, **kwargs)
@classmethod
def find(cls, db, name):
@@ -348,6 +351,7 @@ class APIToken(Hashed, Base):
# token metadata for bookkeeping
created = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, default=None, nullable=True)
last_activity = Column(DateTime)
note = Column(Unicode(1023))
@@ -369,6 +373,22 @@ class APIToken(Hashed, Base):
name=name,
)
@classmethod
def purge_expired(cls, db):
"""Purge expired API Tokens from the database"""
now = utcnow()
deleted = False
for token in (
db.query(cls)
.filter(cls.expires_at != None)
.filter(cls.expires_at < now)
):
app_log.debug("Purging expired %s", token)
deleted = True
db.delete(token)
if deleted:
db.commit()
@classmethod
def find(cls, db, token, *, kind=None):
"""Find a token object by value.
@@ -379,6 +399,9 @@ class APIToken(Hashed, Base):
`kind='service'` only returns API tokens for services
"""
prefix_match = cls.find_prefix(db, token)
prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= utcnow())
)
if kind == 'user':
prefix_match = prefix_match.filter(cls.user_id != None)
elif kind == 'service':
@@ -390,7 +413,8 @@ class APIToken(Hashed, Base):
return orm_token
@classmethod
def new(cls, token=None, user=None, service=None, note='', generated=True):
def new(cls, token=None, user=None, service=None, note='', generated=True,
expires_in=None):
"""Generate a new API token for a user or service"""
assert user or service
assert not (user and service)
@@ -412,6 +436,8 @@ class APIToken(Hashed, Base):
else:
assert service.id is not None
orm_token.service = service
if expires_in is not None:
orm_token.expires_at = utcnow() + timedelta(seconds=expires_in)
db.add(orm_token)
db.commit()
return token
@@ -549,7 +575,7 @@ def _expire_relationship(target, relationship_prop):
def _notify_deleted_relationships(session, obj):
"""Expire relationships when an object becomes deleted
Needed for
Needed to keep relationships up to date.
"""
mapper = inspect(obj).mapper
for prop in mapper.relationships:
@@ -557,6 +583,52 @@ def _notify_deleted_relationships(session, obj):
_expire_relationship(obj, prop)
def register_ping_connection(engine):
"""Check connections before using them.
Avoids database errors when using stale connections.
From SQLAlchemy docs on pessimistic disconnect handling:
https://docs.sqlalchemy.org/en/rel_1_1/core/pooling.html#disconnect-handling-pessimistic
"""
@event.listens_for(engine, "engine_connect")
def ping_connection(connection, branch):
if branch:
# "branch" refers to a sub-connection of a connection,
# we don't want to bother pinging on these.
return
# turn off "close with result". This flag is only used with
# "connectionless" execution, otherwise will be False in any case
save_should_close_with_result = connection.should_close_with_result
connection.should_close_with_result = False
try:
# run a SELECT 1. use a core select() so that
# the SELECT of a scalar value without a table is
# appropriately formatted for the backend
connection.scalar(select([1]))
except exc.DBAPIError as err:
# catch SQLAlchemy's DBAPIError, which is a wrapper
# for the DBAPI's exception. It includes a .connection_invalidated
# attribute which specifies if this connection is a "disconnect"
# condition, which is based on inspection of the original exception
# by the dialect in use.
if err.connection_invalidated:
app_log.error("Database connection error, attempting to reconnect: %s", err)
# run the same SELECT again - the connection will re-validate
# itself and establish a new connection. The disconnect detection
# here also causes the whole connection pool to be invalidated
# so that all stale connections are discarded.
connection.scalar(select([1]))
else:
raise
finally:
# restore "close with result"
connection.should_close_with_result = save_should_close_with_result
def check_db_revision(engine):
"""Check the JupyterHub database revision
@@ -635,10 +707,12 @@ def mysql_large_prefix_check(engine):
else:
return False
def add_row_format(base):
for t in base.metadata.tables.values():
t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC'
def new_session_factory(url="sqlite:///:memory:",
reset=False,
expire_on_commit=False,
@@ -658,6 +732,9 @@ def new_session_factory(url="sqlite:///:memory:",
kwargs.setdefault('poolclass', StaticPool)
engine = create_engine(url, **kwargs)
# enable pessimistic disconnect handling
register_ping_connection(engine)
if reset:
Base.metadata.drop_all(engine)

View File

@@ -703,10 +703,11 @@ class Spawner(LoggingConfigurable):
def run_post_stop_hook(self):
"""Run the post_stop_hook if defined"""
try:
return self.post_stop_hook(self)
except Exception:
self.log.exception("post_stop_hook failed with exception: %s", self)
if self.post_stop_hook is not None:
try:
return self.post_stop_hook(self)
except Exception:
self.log.exception("post_stop_hook failed with exception: %s", self)
@property
def _progress_url(self):

View File

@@ -8,19 +8,22 @@ from subprocess import check_output, Popen, PIPE
from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch
from tornado import gen
import pytest
from tornado import gen
from traitlets.config import Config
from .mocking import MockHub
from .test_api import add_user
from .. import orm
from ..app import COOKIE_SECRET_BYTES
from ..app import COOKIE_SECRET_BYTES, JupyterHub
def test_help_all():
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
assert '--ip' in out
assert '--JupyterHub.ip' in out
def test_token_app():
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
@@ -30,6 +33,7 @@ def test_token_app():
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
assert re.match(r'^[a-z0-9]+$', out)
def test_generate_config():
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
cfg_file = tf.name
@@ -218,3 +222,51 @@ def test_resume_spawners(tmpdir, request):
assert not user.running
assert user.spawner.server is None
assert list(db.query(orm.Server)) == []
@pytest.mark.parametrize(
'hub_config, expected',
[
(
{'ip': '0.0.0.0'},
{'bind_url': 'http://0.0.0.0:8000/'},
),
(
{'port': 123, 'base_url': '/prefix'},
{
'bind_url': 'http://:123/prefix/',
'base_url': '/prefix/',
},
),
(
{'bind_url': 'http://0.0.0.0:12345/sub'},
{'base_url': '/sub/'},
),
(
# no config, test defaults
{},
{
'base_url': '/',
'bind_url': 'http://:8000',
'ip': '',
'port': 8000,
},
),
]
)
def test_url_config(hub_config, expected):
# construct the config object
cfg = Config()
for key, value in hub_config.items():
cfg.JupyterHub[key] = value
# instantiate the Hub and load config
app = JupyterHub(config=cfg)
# validate config
for key, value in hub_config.items():
if key not in expected:
assert getattr(app, key) == value
# validate additional properties
for key, value in expected.items():
assert getattr(app, key) == value

View File

@@ -3,8 +3,10 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta
import os
import socket
from unittest import mock
import pytest
from tornado import gen
@@ -99,6 +101,32 @@ def test_tokens(db):
assert len(user.api_tokens) == 3
def test_token_expiry(db):
user = orm.User(name='parker')
db.add(user)
db.commit()
now = datetime.utcnow()
token = user.new_api_token(expires_in=60)
orm_token = orm.APIToken.find(db, token=token)
assert orm_token
assert orm_token.expires_at is not None
# approximate range
assert orm_token.expires_at > now + timedelta(seconds=50)
assert orm_token.expires_at < now + timedelta(seconds=70)
the_future = mock.patch('jupyterhub.orm.utcnow', lambda : now + timedelta(seconds=70))
with the_future:
found = orm.APIToken.find(db, token=token)
assert found is None
# purging shouldn't delete non-expired tokens
orm.APIToken.purge_expired(db)
assert orm.APIToken.find(db, token=token)
with the_future:
orm.APIToken.purge_expired(db)
assert orm.APIToken.find(db, token=token) is None
# after purging, make sure we aren't in the user token list
assert orm_token not in user.api_tokens
def test_service_tokens(db):
service = orm.Service(name='secret')
db.add(service)

View File

@@ -3,6 +3,7 @@
from urllib.parse import urlencode, urlparse
from tornado import gen
from tornado.httputil import url_concat
from ..handlers import BaseHandler
from ..utils import url_path_join as ujoin
@@ -53,6 +54,30 @@ def test_root_redirect(app):
assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name)
@pytest.mark.gen_test
def test_root_default_url_noauth(app):
with mock.patch.dict(app.tornado_settings,
{'default_url': '/foo/bar'}):
r = yield get_page('/', app, allow_redirects=False)
r.raise_for_status()
url = r.headers.get('Location', '')
path = urlparse(url).path
assert path == '/foo/bar'
@pytest.mark.gen_test
def test_root_default_url_auth(app):
name = 'wash'
cookies = yield app.login_user(name)
with mock.patch.dict(app.tornado_settings,
{'default_url': '/foo/bar'}):
r = yield get_page('/', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
url = r.headers.get('Location', '')
path = urlparse(url).path
assert path == '/foo/bar'
@pytest.mark.gen_test
def test_home_no_auth(app):
r = yield get_page('home', app, allow_redirects=False)
@@ -342,29 +367,50 @@ def test_login_strip(app):
assert called_with == [form_data]
@pytest.mark.parametrize(
'running, next_url, location',
[
# default URL if next not specified, for both running and not
(True, '', ''),
(False, '', ''),
# next_url is respected
(False, '/hub/admin', '/hub/admin'),
(False, '/user/other', '/hub/user/other'),
(False, '/absolute', '/absolute'),
(False, '/has?query#andhash', '/has?query#andhash'),
# next_url outside is not allowed
(False, 'https://other.domain', ''),
(False, 'ftp://other.domain', ''),
(False, '//other.domain', ''),
]
)
@pytest.mark.gen_test
def test_login_redirect(app):
def test_login_redirect(app, running, next_url, location):
cookies = yield app.login_user('river')
user = app.users['river']
# no next_url, server running
yield user.spawn()
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/user/river' in r.headers['Location']
if location:
location = ujoin(app.base_url, location)
else:
# use default url
location = user.url
# no next_url, server not running
yield user.stop()
r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/user/river' in r.headers['Location']
url = 'login'
if next_url:
if '//' not in next_url:
next_url = ujoin(app.base_url, next_url, '')
url = url_concat(url, dict(next=next_url))
# next URL given, use it
r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
if running and not user.active:
# ensure running
yield user.spawn()
elif user.active and not running:
# ensure not running
yield user.stop()
r = yield get_page(url, app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert r.headers['Location'].endswith('/hub/admin')
assert location == r.headers['Location']
@pytest.mark.gen_test
@@ -447,6 +493,21 @@ def test_token_auth(app):
assert r.status_code == 200
@pytest.mark.gen_test
def test_oauth_token_page(app):
name = 'token'
cookies = yield app.login_user(name)
user = app.users[orm.User.find(app.db, name)]
client = orm.OAuthClient(identifier='token')
app.db.add(client)
oauth_token = orm.OAuthAccessToken(client=client, user=user, grant_type=orm.GrantType.authorization_code)
app.db.add(oauth_token)
app.db.commit()
r = yield get_page('token', app, cookies=cookies)
r.raise_for_status()
assert r.status_code == 200
@pytest.mark.parametrize("error_status", [
503,
404,

View File

@@ -3,9 +3,11 @@
{% block main %}
<div class="container">
{% block heading %}
<div class="row text-center">
<h1>Spawner options</h1>
<h1>Spawner Options</h1>
</div>
{% endblock %}
<div class="row col-sm-offset-2 col-sm-8">
{% if for_user and user.name != for_user.name -%}
<p>Spawning server for {{ for_user.name }}</p>