Compare commits

..

310 Commits
0.1.0 ... 0.4.0

Author SHA1 Message Date
Min RK
ad5624c7ce release 0.4.0 2016-02-01 10:37:16 +01:00
Min RK
a7d6c37d26 Merge pull request #400 from willingc/juphub-spawner
Edit tone and grammar in Spawners document
2016-01-29 21:57:51 +01:00
Min RK
b8d9954c28 Merge pull request #402 from mistercrunch/fix_custom_html
Fixing the custom_html feature in the login form
2016-01-29 21:02:45 +01:00
Maxime Beauchemin
927a341764 Fixing the custom_html feature in the login form 2016-01-28 11:25:19 -08:00
Carol Willing
83d092b0ad Minor edit 2016-01-27 22:57:42 -08:00
Carol Willing
95f7889803 Edit the custom spawners doc 2016-01-27 16:48:59 -08:00
Matthias Bussonnier
ceacd72d63 Merge pull request #399 from willingc/newci-badge
Add circleci badge with status only API token
2016-01-25 17:02:29 -08:00
Carol Willing
49c0fa4f08 Add circleci badge with status only API token 2016-01-25 10:56:55 -08:00
Min RK
223318bfff Merge pull request #396 from minrk/test-fixes
If spawner fails to start, show error page
2016-01-25 14:55:07 +01:00
Min RK
9c3f953682 mock pam close session
bug revealed by change in slow_spawn test
2016-01-25 14:29:20 +01:00
Min RK
cc4c65bd0b fix possible loss of port info due to mixed db sessions 2016-01-25 14:28:54 +01:00
Min RK
c4fad21850 If spawner fails to start, show error page
instead of slow-spawner page
2016-01-25 13:32:54 +01:00
Min RK
665907afd3 remove login from default handlers
rely on getting it from LoginHandler
2016-01-25 13:21:21 +01:00
Min RK
8a4305a15c s/chose/choose/ typo 2016-01-25 12:57:19 +01:00
Min RK
7e59148168 ignore node_modules 2016-01-25 12:56:51 +01:00
Min RK
98b44d59c4 Merge pull request #395 from minrk/docker-test
Test docker builds on CircleCI
2016-01-25 12:55:01 +01:00
Min RK
aac357b715 Merge pull request #392 from evanlinde/master
username parameter for notebook_dir
2016-01-25 12:50:47 +01:00
Min RK
2632d03dc2 Merge pull request #391 from minrk/form-error
show error messages on spawn form
2016-01-25 12:46:04 +01:00
Min RK
babb2cf908 test docker builds on circle-ci 2016-01-25 12:32:32 +01:00
Min RK
6a3d790f49 install locale in Dockerfile
and do a little cleanup of temporary installation files
2016-01-25 12:32:32 +01:00
Min RK
9cae91aeb0 Merge pull request #393 from willingc/fix-mdlink
Use relative html link instead of local md
2016-01-22 22:52:01 +01:00
Carol Willing
84f8f8f322 Use relative html link instead of local md 2016-01-22 08:06:30 -08:00
evanlinde
bc4973fb43 username parameter for notebook_dir
Allow specifying user-specific notebook directories outside of user's home folder
2016-01-22 09:47:48 -06:00
Min RK
1a21e822b6 Merge pull request #389 from willingc/fix-docstring
Fix docstrings *ix -> Linux/UNIX to prevent Sphinx build warnings

closes #389
2016-01-22 16:05:31 +01:00
Carol Willing
d437a8f06a Fix docstrings *ix -> Linux/UNIX 2016-01-22 16:05:15 +01:00
Min RK
0555ee44e7 turn on jinja autoescape
now that we are putting user content on the page
2016-01-22 16:02:51 +01:00
Min RK
ef40bd230e Show error messages on spawn form
when spawning fails

instead of 500
2016-01-22 16:02:11 +01:00
Min RK
818510c2ca Merge pull request #381 from minrk/rtd-yml
add preliminary API docs
2016-01-22 12:03:57 +01:00
Min RK
caaab40944 Merge pull request #386 from minrk/dockerfile-jessie
Base Dockerfile on debian:jessie
2016-01-21 13:05:35 +01:00
Min RK
0fb80d43b6 Merge pull request #387 from minrk/single-user-script
make jupyterhub-singleuser a script
2016-01-21 12:55:21 +01:00
Min RK
8146af7240 make jupyterhub-singleuser a script
instead of a module in the package

makes it easier to do `/path/to/python $(which jupyterhub-singleuser)`
2016-01-20 15:41:54 +01:00
Min RK
b9df681115 Merge pull request #353 from minrk/try-localhost
Ensure that we can bind and connect to localhost
2016-01-20 15:37:42 +01:00
Min RK
40a3ebde84 Merge pull request #354 from zoltan-fedor/master
IPv6 ready /etc/hosts file without IPv6 enabled causing localhost issue
2016-01-20 15:37:32 +01:00
Min RK
fbf3b45d52 needs sphinx 1.3 2016-01-20 15:36:26 +01:00
Min RK
eb0a38c136 add preliminary API docs 2016-01-20 15:36:24 +01:00
Min RK
37d42a336f put repo on path
allows autodoc to import jupyterhub without installing it
2016-01-20 15:35:49 +01:00
Min RK
51a04258d1 build on readthedocs 2016-01-20 15:35:49 +01:00
Min RK
1a4226419f Base Dockerfile on debian:jessie
rather than jupyter/notebook

and use conda to get Python 3.5

No longer includes single-user server dependencies
2016-01-20 14:33:39 +01:00
Min RK
ce4cc62c05 Merge pull request #383 from minrk/start-new-session
use start_new_session to detach single-user servers
2016-01-15 17:55:37 +01:00
Min RK
614a0806f5 use start_new_session to detach single-user servers
instead of setpgrp, which causes various problems
2016-01-15 14:21:45 +01:00
Min RK
ff2fef1617 Merge pull request #373 from minrk/normalize-username
Username normalization and validation
2016-01-14 10:16:45 +01:00
Carol Willing
2e6f08268b Merge pull request #380 from minrk/installation
move install commands around a bit
2016-01-13 06:42:36 -08:00
Min RK
ff4019128a move install commands around a bit
npm/less notes are only relevant for dev installs
2016-01-13 15:10:22 +01:00
Min RK
6fd18840a7 Merge pull request #378 from willingc/readme-rtd
Update project README to reflect docs on RTD
2016-01-13 15:07:21 +01:00
Min RK
108d710dcb doc: username normalization and validation 2016-01-13 14:02:51 +01:00
Min RK
aa93384f47 Include system-user creation error message in API reply
when system-user creation fails
2016-01-13 14:02:50 +01:00
Min RK
9441fa37c5 validate usernames
via Authenticator.validate_username

base class configurable with Authenticator.username_pattern
2016-01-13 14:02:50 +01:00
Min RK
beb2dae6ce add username_map 2016-01-13 14:02:50 +01:00
Min RK
887fdaf9d3 add username normalization
Handlers call `get_authenticated_user`, which in turn calls

- authenticate
- normalize_username
- check_whitelist

get_authenticated_user shouldn't need to be overridden.

Normalization can be handled via overriding normalize_username.
2016-01-13 14:02:50 +01:00
Min RK
8a5a85a489 Merge pull request #377 from minrk/swagger-spec
add swagger spec for REST API
2016-01-13 13:08:01 +01:00
Carol Willing
2cc49d317b Add more wording tweaks 2016-01-12 13:55:10 -08:00
Carol Willing
4afa358201 Add some minor formatting 2016-01-12 13:49:04 -08:00
Carol Willing
50a58e5e81 Update README after docs move to RTD 2016-01-12 13:44:08 -08:00
Min RK
479b40d840 add swagger spec for REST API 2016-01-12 16:32:50 +01:00
Min RK
931c2d6f8a Merge pull request #368 from willingc/doc-wip
Sphinx documentation that converts markdown using recommonmark
2016-01-12 16:31:55 +01:00
Min RK
f5746d0765 Merge pull request #375 from betatim/form-file-upload
Handle file upload in spawner form
2016-01-09 23:25:52 +01:00
Tim Head
a59f57e095 Handle file upload in spawner form
Allow files to be uploaded in the spawner form.
2016-01-09 13:53:45 +01:00
Min RK
47549e752d Merge pull request #371 from minrk/delete-user
delete users via UserDict API
2016-01-08 10:32:10 +01:00
Min RK
4534bea86e delete users via UserDict API
avoids reusing user IDs when user creation fails
2016-01-06 15:14:28 +01:00
Carol Willing
2815f72250 Change mocking of slowspawner to match nospawner 2016-01-05 19:45:49 -08:00
Carol Willing
131b695fbb Correct some links 2016-01-05 19:45:49 -08:00
Carol Willing
1bc0d208d3 Move image files 2016-01-05 19:45:49 -08:00
Carol Willing
46a9e8b1c3 Update doc requirements 2016-01-05 19:45:49 -08:00
Carol Willing
04cb5fe503 Add recommonmark parser for markdown 2016-01-05 19:45:49 -08:00
Carol Willing
0ad110f7de Add parsers 2016-01-05 19:45:49 -08:00
Carol Willing
0c5c3eb8b1 Add recommonmark 2016-01-05 19:45:49 -08:00
Carol Willing
bd8b8c55b2 Add initial index file 2016-01-05 19:45:49 -08:00
Carol Willing
e52d2eb27d Add Jupyter customizations 2016-01-05 19:45:49 -08:00
Carol Willing
0b4fbee418 Add sphinx skeleton 2016-01-05 19:45:49 -08:00
Carol Willing
9ee92a3984 Add a requirements for building docs 2016-01-05 19:45:49 -08:00
Carol Willing
f4de573198 Set up docs directory for Sphinx 2016-01-05 19:45:49 -08:00
Min RK
26e00718f9 Merge pull request #366 from minrk/double-redirect
return after redirect to spawner form
2016-01-05 17:19:04 +01:00
Min RK
c878e137aa try codecov for coverage 2016-01-05 14:05:59 +01:00
Min RK
53785a985d return after redirect to spawner form
avoids double-call to redirect, which fails
2016-01-05 14:02:20 +01:00
Min RK
b0cc47984b Merge pull request #364 from minrk/spawn-typo
s/users/user typo in spawn redirect
2015-12-31 17:30:56 +01:00
Min RK
91168fc22b s/users/user typo in spawn redirect 2015-12-31 12:06:04 +01:00
Min RK
66cbb8a614 more testing of spawn page redirects 2015-12-31 12:05:55 +01:00
Min RK
0fbd69be9b Merge pull request #355 from minrk/spawner-options
Add Spawner form page
2015-12-30 16:40:16 +01:00
Min RK
872005f852 document spawner options form 2015-12-30 14:17:58 +01:00
Min RK
647dd09f40 add spawn-form example 2015-12-30 13:55:39 +01:00
Min RK
041c1a4a1e remove always-False else branch 2015-12-30 13:55:38 +01:00
Min RK
d2e3a73f53 set login cookie after starting server
avoids redirect loop
2015-12-30 13:55:38 +01:00
Min RK
2bd7192e89 add extensible get_env hook on Spawner
to make it easier for subclasses to modify the env
2015-12-30 13:55:38 +01:00
Min RK
28f5f33a76 add bootstrap form-control to spawner form inputs 2015-12-30 13:55:38 +01:00
Min RK
f9c9c2b471 options_form is a regular configurable
now that we can assume User.spawner exists at all times
2015-12-30 13:55:38 +01:00
Min RK
41ea696546 Instantiate Spawner on User init
shrinks `User.spawn` to take single argument, grants User more direct access to state.
2015-12-30 13:55:36 +01:00
Min RK
54f9a296de test Spawner.user_options and spawn form 2015-12-30 13:55:01 +01:00
Min RK
ba634354dd Add Spawner form
If Spawner.options_form is specified, a form providing input controls is shown to the user prior to launch.

Spawners access the result via the `self.user_options` dict.

The default spawners offer no form.
2015-12-30 13:55:01 +01:00
Min RK
675f19b5cb Merge pull request #358 from minrk/ipython-traitlets
import base traitlets
2015-12-27 22:26:31 +01:00
Min RK
1eed96193d import base traitlets
missed IPython.utils.traitlets import from old User PR
2015-12-24 12:25:43 +01:00
Zoltan Fedor
faa259e97b IPv6 ready hosts file localhost issue
This is to resolve the 'Network is Unreachable' error experienced by a few when JupyterHUB is connecting to localhost.

On most recent linux OS versions like CentOS 6, 7, Red Hat 6, 7, Oracle Linux 6, 7, etc, the hosts file (/etc/hosts) usually has a line to make the server IPv6-ready:
    ::1 localhost
even if the given server actually has no IPv6 permissioned. In such case the Python socket library when connecting to 'localhost' will try to connect via the IPv6 protocol - which will fail with the 'Network is Unreachable' error.

To solve this we capture this error and try to reconnect on 127.0.0.1 instead of localhost, alias forcing the user of the IPv4 protocol.
2015-12-15 10:53:06 -05:00
Min RK
4785a1ef87 Ensure that we can bind and connect to localhost
otherwise fallback to 127.0.0.1 for defaults
2015-12-15 13:37:30 +01:00
Min RK
aa529f3aba Merge pull request #352 from minrk/sqlalchemy-1.0
require sqlalchemy 1.0
2015-12-14 14:11:52 +01:00
Min RK
98955a5702 require sqlalchemy 1.0
we know 0.7.9 is too old. We might work on 0.8,
but 1.0 is current.
2015-12-14 10:37:48 +01:00
Min RK
2f1a203699 Merge pull request #349 from minrk/adduser
create users with adduser
2015-12-13 13:43:02 +01:00
Brian E. Granger
77b31d8542 Minor fixes to the PR on docs and the default command. 2015-12-13 12:15:31 +01:00
Min RK
8fca4e859d create users with adduser
instead of useradd (on Linux).

- still user `pw useradd` on BSD
- allow complete custom add_user_cmd for specifying directories, etc.
2015-12-13 12:15:31 +01:00
Brian E. Granger
8d90a92ef3 Merge pull request #351 from ellisonbg/token-docs
Edits to the security part of the docs
2015-12-12 15:29:09 -08:00
Brian E. Granger
37424acabf Adding actual secret key... 2015-12-12 15:28:12 -08:00
Brian E. Granger
86a450da77 Edits to the security part of the docs 2015-12-12 14:30:19 -08:00
Min RK
151dcbafb4 Merge pull request #347 from dblockow/feature/log-failed-auth-ip
Log Remote IP Address for Failed Authentication Attempts
2015-12-10 22:51:48 +01:00
David Blockow
d512ee9f65 Fixed to cope with a None handler passed in tests 2015-12-08 15:50:54 +10:30
David Blockow
e59b3f3ab1 Remote IP logged for failed authentication attempts 2015-12-08 15:00:29 +10:30
Min RK
2e7af82865 Merge pull request #185 from minrk/outer-user
move non-persisted User objects (spawner-related) off of orm.User
2015-12-02 12:44:47 +01:00
Min RK
49d4be002b Merge pull request #344 from minrk/system-user-home
add LocalAuthenticator.system_user_home
2015-12-02 10:29:14 +01:00
Min RK
fa8756767d add LocalAuthenticator.system_user_home 2015-12-01 10:36:06 +01:00
Min RK
6f128758db move non-persisted User objects (spawner-related) off of orm.User
adds higher level User object, which handles spawning.
This object has running, spawner, etc. attributes.
2015-11-30 14:05:00 +01:00
Min RK
235746a484 Merge pull request #338 from minrk/dockerfile
install nodejs with one command in Dockerfile
2015-11-25 15:01:57 +01:00
Min RK
37f736cf45 install nodejs with one command in Dockerfile 2015-11-17 14:54:06 +01:00
Min RK
5376291eaa Merge pull request #336 from Fokko/master
Added npm/node which fixes the Dockerfile
2015-11-17 14:52:54 +01:00
Fokko Driesprong
9e738a62d1 Added npm/node which fixes the Dockerfile 2015-11-16 15:55:41 +01:00
Min RK
8bfe52df4f Merge pull request #334 from cwaldbieser/custom_login
Load Authenticator handlers before default handlers
2015-11-10 12:40:57 +01:00
Carl Waldbieser
91ff31f688 Change the order for handlers so that the authenticator handler is added before the default handlers. 2015-11-09 14:17:26 -05:00
Min RK
b7fe3463cf back to dev 2015-11-04 17:13:41 +01:00
Min RK
4931684a2c release 0.3 2015-11-04 17:10:36 +01:00
Min RK
62d3cc53ef changelog for 0.3 2015-11-04 17:09:34 +01:00
Min RK
bd002e5340 Merge pull request #325 from minrk/authenticator-hooks
add pre/post-spawn hooks for Authenticators
2015-11-04 16:07:01 +00:00
Min RK
6f2aefb990 add pre/post-spawn hooks for Authenticators
allows setup/cleanup to be performed by the authenticator

use this to open PAM sessions at spawn
and close them at stop,
rather than open at login and never close.
2015-10-16 12:02:44 +02:00
Min RK
bd3c878c67 Merge pull request #320 from minrk/authenticator-username
get username from authenticator
2015-10-06 15:43:22 +02:00
Min RK
c1de376b6a Merge pull request #310 from minrk/singleuser-notebook
single-user imports notebook package directly
2015-10-06 14:08:35 +02:00
Min RK
4cc74d287e get username from authenticator 2015-10-06 13:36:34 +02:00
Min RK
411a7a0bd8 single-user imports notebook package directly
instead of relying on IPython.html shims

when should we drop support for IPython 3?
2015-09-24 16:13:28 +02:00
Min RK
498c062ee0 Merge pull request #309 from minrk/gen.sleep
use gen.sleep
2015-09-24 16:09:55 +02:00
Min RK
d1edbddb77 use gen.sleep
instead of elaborate `gen.Task(add_timeout...)`

requires tornado 4.1
2015-09-23 17:04:01 +02:00
Min RK
0c9214ffb7 Merge pull request #307 from minrk/test-3.5
test on 3.5
2015-09-22 14:17:30 +02:00
Min RK
db0aaf1027 test on 3.5
requires pytest >= 2.8
2015-09-22 14:09:23 +02:00
Min RK
42681f8512 Merge pull request #306 from minrk/test-token-username
update token app test
2015-09-22 14:08:41 +02:00
Min RK
e5c1414b6a update token app test
now that admin user isn't added by default
2015-09-22 10:14:11 +02:00
Min RK
d857c20de0 Merge pull request #304 from minrk/rm-default-admin
Remove implicit admin of launching user
2015-09-22 08:59:28 +02:00
Min RK
a267174a03 Remove implicit admin of launching user
instead, warn about missing admins and point to config.
2015-09-21 10:52:19 +02:00
Min RK
768eeee470 Merge pull request #298 from minrk/spawner-authenticator
give Spawners a handle on the Authenticator
2015-09-11 14:24:38 +02:00
Min RK
a451f11cd3 give Spawners a handle on the Authenticator
band-aid for spawner-authenticator pairs
2015-09-11 11:57:41 +02:00
Min RK
63a476f9a6 remove some unused cruft from spawner 2015-09-11 11:23:00 +02:00
Min RK
100b17819d Merge pull request #296 from minrk/pamela
use pamela instead of simplepam
2015-09-11 11:02:14 +02:00
Min RK
024d8d7378 update mocking for pamela 2015-09-09 14:24:53 +02:00
Min RK
15e50529ff use pamela instead of simplepam
and open PAM sessions after successful auth
2015-09-09 13:55:02 +02:00
Min RK
a1a10be747 Merge pull request #290 from jhamrick/clear-login-cookies
Unset all login cookies
2015-08-22 18:55:30 -07:00
Jessica B. Hamrick
a91ee67e74 Reset other_user_cookies after clearing them 2015-08-22 13:14:05 -07:00
Jessica B. Hamrick
ea5bfa9999 Unset all login cookies 2015-08-21 19:24:44 -07:00
Min RK
bea58ee622 Merge pull request #288 from minrk/dont-auto-redirect-root
redirect unauthenticated root to *regular* login page
2015-08-19 21:44:00 -07:00
Min RK
b698d4d226 redirect root to *regular* login page
shows "Login with..." button for external services
instead of auto-redirecting to login service
(no good for oauth)
2015-08-19 12:43:32 -07:00
Min RK
139c7ecacb always render login page at /login 2015-08-19 12:30:10 -07:00
Min RK
eefa8fcad7 Merge pull request #284 from minrk/double-base-url
remove double base_url in login redirect
2015-08-06 21:48:49 -07:00
Min RK
acaedcd898 remove double base_url in login redirect
user.server.base_url is already correct,
and shouldn't be joined with the hub url
2015-08-06 21:37:06 -07:00
Min RK
a075661bfb Merge pull request #276 from Crunch-io/redirect-to-login
Redirect unauthenticated root to login
2015-07-23 13:00:16 -07:00
Joseph Tate
f2246df5bb Fix logging and comments 2015-07-23 15:08:53 -04:00
Joseph Tate
1a3c062512 Fix broken test 2015-07-23 15:06:20 -04:00
Joseph Tate
05e4ab41fe Redirect to the loginurl when not logged in, fix the user.running redirect to fix a redirect loop 2015-07-23 15:06:03 -04:00
Min RK
6f3ccb2d3d Merge pull request #275 from jhamrick/installation-instructions
Update installation instructions
2015-07-14 22:06:52 -07:00
Jessica B. Hamrick
6e5ce236c1 Update installation instructions 2015-07-14 15:36:35 -07:00
Min RK
58437057a1 back to dev 2015-07-12 15:30:47 -05:00
Min RK
7d39e6a1a3 release 0.2.0 2015-07-12 15:30:02 -05:00
Min RK
0b1aebbbf4 Merge pull request #274 from minrk/check-referer
port security fixes from IPython
2015-07-12 15:27:26 -05:00
Min RK
3003c87f02 check Referer on API requests
For CSRF
2015-07-12 14:23:02 -05:00
Min RK
2c8c88ac3f add CSP report handler 2015-07-12 11:22:21 -05:00
Min RK
db994e09d3 add OPTIONS on API handlers 2015-07-12 11:22:21 -05:00
Min RK
357ba23ff3 set csp_report_uri in single-user server
so Hub gets CSP reports instead of single-user servers.
2015-07-12 11:22:21 -05:00
Min RK
54c0c276ed use hmac.compare_digest for constant time comparisons on tokens 2015-07-12 11:22:21 -05:00
Min RK
baf8bd9e03 Merge pull request #269 from minrk/travis-segfaulty
Use thread local storage to avoid sharing db sessions in tests
2015-07-09 15:08:55 -05:00
Min RK
e866abe1a0 use thread-local properties for hub, proxy 2015-07-09 11:34:35 -05:00
Min RK
48fe642c44 use thread local db sessions
to avoid segfaults on Travis
2015-07-09 11:34:35 -05:00
Min RK
c8487c2117 Merge pull request #268 from dietmarw/patch-1
ipython/ipython is deprecated.
2015-07-09 09:19:47 -05:00
Dietmar Winkler
0d6ee3c63c ipython/ipython is deprecated.
The correct one is now jupyter/notebook
2015-07-09 09:53:18 +02:00
Min RK
a00abc7a76 Merge pull request #267 from minrk/strict-next
require next_url to be an absolute path
2015-07-07 12:33:19 -05:00
Min RK
02c8855d10 Merge pull request #264
add supplemental groups to single-user servers

closes #264
2015-07-07 12:31:33 -05:00
Min RK
b5877ac546 Catch failure to set gids 2015-07-07 12:28:17 -05:00
Min RK
ea91bed620 require next_url to be an absolute path
- disallow relative path
- disallow full URL (cross-site)
2015-07-07 11:28:44 -05:00
Min RK
3e81e2ebf9 Merge pull request #265 from jhamrick/docs
Add some docs clarifications
2015-06-26 08:13:49 +01:00
Jessica B. Hamrick
e9e2b17a92 Move npm install to a different part of the docs 2015-06-26 01:41:14 -04:00
Jessica B. Hamrick
498181d217 Remove sudo from npm install 2015-06-26 01:37:26 -04:00
Jessica B. Hamrick
9f807a5959 Add some docs clarifications 2015-06-25 14:48:18 -04:00
shreddd
d328015fe8 add supplemental groups to the local user notebook
Set the list of supplemental group ids for the user associated with the spawned notebook process. This allows users to access utilize their full complement of UNIX system groups. Currently the user is restricted to their default group - accessing a file owned by any other group does not work, even if a user is a member of that group. This patch fixes that.
2015-06-24 14:56:44 -07:00
Min RK
cbbc0290b9 Merge pull request #263 from minrk/test-singleuser
single-user should still be using IPython.utils.traitlets
2015-06-24 12:53:25 -07:00
Min RK
7477f2f6d1 make sure to launch single-user server at least once in tests 2015-06-24 08:47:51 -07:00
Min RK
c03e50b3a2 single-user should still be using IPython.utils.traitlets
We haven't moved to using notebook 4.0 in the single-user, yet
2015-06-24 08:46:56 -07:00
Min RK
cfd19c3e61 Merge pull request #261 from minrk/traitlets
remove dependency on IPython
2015-06-22 22:56:08 -07:00
Min RK
c289cdfaec remove dependency on IPython
- Standalone traitlets has been released, use it directly.
- Copy url_path_join from notebook
2015-06-22 16:02:45 -07:00
Min RK
7acaf8ce52 Merge pull request #260 from minrk/notebook-aliases
don't try to get notebook aliases from NotebookApp
2015-06-22 15:59:50 -07:00
Min RK
e5821e573a don't try to get notebook aliases from NotebookApp
Sentinel changes make it ~impossible to fetch values without instantiation,
which probably wasn't the best idea in the first place.
2015-06-22 14:50:37 -07:00
Min RK
0c16fb98f3 Merge pull request #257 from minrk/command-traitlet
add command traitlet
2015-06-19 10:46:38 -07:00
Min RK
b27ef8e4cb we're not in the future, yet. 2015-06-12 14:09:05 -07:00
Min RK
6cfd186f06 proxy_cmd is a list 2015-06-12 14:01:57 -07:00
Min RK
552859084c simplify some installation notes
- use pip3 / python3
2015-06-12 11:22:13 -07:00
Min RK
6e8a58091e prioritize Google Group over Gitter for help 2015-06-12 11:18:44 -07:00
Min RK
6b0aee2443 Merge pull request #259 from nthiery/master
Removed sudo from pip install instructions

closes #258
2015-06-10 16:32:35 -07:00
Nicolas M. Thiéry
8d00ccc506 Removed sudo from pip install instructions, ...
Attempt at implementing @minrk's suggestion

https://github.com/jupyter/jupyterhub/pull/258#issuecomment-110932499
2015-06-11 00:54:32 +02:00
Min RK
86e31dffa5 add command traitlet
allows specifying commands as either strings or list.

This enables adding arguments to JupyterHub.proxy_cmd without breaking backward-compatibility.
2015-06-03 20:05:20 -08:00
Min RK
f421d1a6da Merge pull request #256 from KrishnaPG/master
Added note about pip3 and zmq
2015-05-30 14:35:47 -07:00
Gopalakrishna Palem
9112ad0f4a Added note about pip3 and zmq
Updated Readme.md with optional dependencies
2015-05-30 08:34:16 +05:30
Min RK
354aeb96af Merge pull request #255 from quantopian/query_args
Include query arguments in GET requests to the notebook server (once it spawns)
2015-05-28 13:03:49 -07:00
Tim Shawver
ff1bf7c4c0 Don't strip query string parameters out of GET requests to the notebook server.
Using self.request.uri instead of self.request.path in a few places accomplishes this.
2015-05-28 15:05:39 -04:00
Kyle Kelley
1ff659a847 Merge pull request #251 from minrk/version
fix `jupyterhub --version` output
2015-05-11 15:16:45 -05:00
Min RK
de40310f54 fix jupyterhub --version output 2015-05-11 10:32:46 -07:00
Min RK
72d9592241 avoid import * in __init__ 2015-05-11 10:32:21 -07:00
Fernando Perez
087a93f9ef Merge pull request #250 from minrk/multi-user-add
create multiple users in admin panel
2015-05-06 20:54:23 -07:00
Min RK
4d73f4eedb note that admin is a single value 2015-05-06 15:35:23 -07:00
Min RK
612cc73c3c skip existing users on bulk user creation
rather than aborting if any already exist

if no users are to be created, throw 400
2015-05-06 15:24:34 -07:00
Min RK
c9d02382e3 fixup 2015-05-06 15:14:08 -07:00
Min RK
da647397ac create multiple users in admin panel
usernames separated by lines
2015-05-06 14:03:19 -07:00
Min RK
546d86e888 allow creating multiple users with one API request 2015-05-06 14:01:31 -07:00
Min RK
36bc07b02e add gitter badge 2015-05-05 14:32:24 -07:00
Min RK
81b13c6660 Merge pull request #245 from minrk/user-name
fix auth key in single-user check
2015-05-02 19:01:09 -05:00
Min RK
b0ef2c4c84 fix auth key in single-user check 2015-05-02 15:22:03 -05:00
Min RK
38024c65d8 Merge pull request #209 from minrk/user-model
reply with full user model in auth handlers
2015-05-01 18:32:21 -07:00
Min RK
80997c8297 reply with full user model in auth handlers 2015-05-01 13:43:43 -07:00
Min RK
c467c64e01 move user_model handling to base APIHandler 2015-05-01 13:41:08 -07:00
Min RK
3fd80f9f3a Merge pull request #243 from minrk/url-name
quote usernames in URLs, cookies
2015-04-30 12:07:33 -07:00
Min RK
d4a4d04183 quote usernames
allow @ to be left unescaped in URLs, quote everything in cookie names
2015-04-30 12:04:32 -07:00
Min RK
f6a3f371b4 Merge pull request #241 from toobaz/url_path_join_from_jupyter_notebook
Get url_path_join from jupyter_notebook
2015-04-24 15:51:11 -07:00
Min RK
8fb74c8627 Merge pull request #240 from quantopian/configurable-headers
DEV: Allow configuration of default headers.
2015-04-24 10:14:43 -07:00
Pietro Battiston
fd6e6f1ded Get url_path_join from jupyter_notebook 2015-04-24 12:34:33 +02:00
Scott Sanderson
74d3740921 DEV: Allow configuration of default headers.
Applies Content-Security-Policy: frame-ancestors 'self' by default.
2015-04-24 01:19:25 -04:00
Min RK
1674d2f698 Merge pull request #238 from quantopian/configurable-templates
DEV: Make template search path configurable.
2015-04-23 14:26:40 -07:00
Tim Shawver
e5d9d136da One more place where template_path needed to be changed to template_paths 2015-04-23 12:32:59 -04:00
Scott Sanderson
1d6b16060b DEV: Make template search path configurable. 2015-04-23 11:08:32 -04:00
Min RK
cd268af799 Merge pull request #236 from quantopian/py2-compat
DEV: Python2 compat in singleuser.py
2015-04-20 15:45:40 -07:00
Scott Sanderson
bc37c729ff DEV: Failover for urrlib.parse.quote in PY2. 2015-04-20 16:51:46 -04:00
Min RK
d277951fa7 Merge pull request #232 from minrk/init-order
reorder server init
2015-04-17 12:51:32 -07:00
Min RK
e4b214536d Merge pull request #233 from minrk/single-user-xheaders
trust proxy headers in single-user server
2015-04-17 12:50:56 -07:00
Min RK
713f222e19 trust proxy headers in single-user server
required for request protocol, ip checks to work properly
2015-04-17 10:37:25 -07:00
Min RK
6b32a5c2d8 Merge pull request #231 from Carreau/secure-cookie
Make cookie secure if used over https
2015-04-17 10:33:48 -07:00
Min RK
5dc38b85eb reorder server init
So the Hub private interface isn't the last thing logged,
which caused lots of confusion.
2015-04-17 10:33:03 -07:00
Matthias Bussonnier
494e4fe68b Make cookie secure if used over https 2015-04-17 10:13:28 -07:00
Min RK
778202ada8 Merge pull request #222 from minrk/log-login
log login / logout at info-level
2015-04-12 14:29:58 -07:00
Min RK
6029204383 Merge pull request #191 from minrk/getting-started
add getting started doc
2015-04-12 14:29:44 -07:00
Min RK
30eef4d353 finish up first round of getting-started 2015-04-12 14:12:04 -07:00
Min RK
b30be43d22 move admin_users from JupyterHub to Authenticator 2015-04-12 14:12:02 -07:00
Brian E. Granger
ca1380eb06 Addressing review comments. 2015-04-12 14:10:55 -07:00
Brian E. Granger
491ee38a37 More edits... 2015-04-12 14:10:55 -07:00
Brian E. Granger
5a9687b02a Editing getting started doc. 2015-04-12 14:10:55 -07:00
Min RK
6b09ff6ef2 add getting started doc 2015-04-12 14:10:55 -07:00
Brian E. Granger
bdbb6164d5 Merge pull request #228 from minrk/no-empty-shell
don't set empty values for HOME, SHELL
2015-04-12 11:09:22 -07:00
Min RK
2890e27052 don't set empty values for HOME, SHELL
in weird cases (probably misconfigured systems),
these can be empty strings.
Leave them unset in such cases.
2015-04-12 11:04:17 -07:00
Min RK
43f13086cf Merge pull request #226 from minrk/last-activity-stop
don't update last_activity on shutdown
2015-04-09 09:58:08 -07:00
Min RK
e883fccf2b don't update last_activity on shutdown 2015-04-08 12:48:04 -07:00
Min RK
364c648d6f Merge pull request #223 from minrk/token-init-hub
assign hub in token app
2015-04-08 11:58:10 -07:00
Min RK
637cc1a7bb split user init into two stages
- init_users populates users table
- init_spawners initializes spawner objects

only the first is needed by the token app
2015-04-08 11:47:49 -07:00
Min RK
6aae4be54d assign hub in token app
avoids AttributeError on hub if there are
users with running servers.

Don't call init_hub,
which can modify the Hub's entries in the database,
which shouldn't happen in the token command.
2015-04-08 11:06:09 -07:00
Min RK
dbc410d6a1 log login / logout at info-level 2015-04-08 10:49:13 -07:00
Brian E. Granger
7ed9c9b6c0 Merge pull request #221 from minrk/debug-clear-cookie
Demote cookie clear message to debug-level
2015-04-07 21:58:39 -07:00
Min RK
ffece0ae79 Demote cookie clear message to debug-level 2015-04-07 21:56:25 -07:00
Min RK
59fda9632a Merge pull request #220 from minrk/coverage
add some test coverage
2015-04-07 16:21:45 -07:00
Min RK
998fc28c32 various testing cleanup
- Disable signal register during testing.
  It doesn't work in background threads.
- Fix IOLoop instance management.
  Some instances were being reused across tests.
2015-04-07 16:09:27 -07:00
Min RK
34386ba3b7 more authenticator coverage 2015-04-07 15:49:25 -07:00
Min RK
64c4d00756 test add_system_user 2015-04-07 15:49:25 -07:00
Min RK
04b7056591 fix group-whitelist checks
and test it
2015-04-07 15:49:25 -07:00
Min RK
d9fc40652d test shutdown API handler 2015-04-07 15:49:25 -07:00
Min RK
d0b4e5bc2a add some basic exercise for HTML pages 2015-04-07 15:49:24 -07:00
Min RK
9372d5f872 add coverage 2015-04-07 15:49:24 -07:00
Min RK
ce59815e16 Merge pull request #205 from minrk/page-flow
Update page flow based on dev meeting
2015-04-07 11:12:07 -07:00
Min RK
7c5e89faa6 use jupyter logo 2015-04-06 10:56:36 -07:00
Min RK
0fe3dab408 use jinja FunctionLoader instead of monkey patch to add Control Panel button 2015-03-31 15:04:17 -07:00
Min RK
789ee44d85 Merge pull request #217 from kyper-data/master
switched app and singleuser to run under python3 by default
2015-03-31 14:27:08 -07:00
Min RK
163a4db3ad single-user login url is now the root hub page 2015-03-31 13:58:09 -07:00
Min RK
50d1f78b61 add control panel link to single-user header
This is done by defining the `headingcontainer` block as a function, and inserting it into the template rendering.

Without monkeypatching, we could use a custom template file,
but that would make the single-user server require its own template path,
while it currently functions as a fully encapsulated single script.
2015-03-31 13:58:07 -07:00
Min RK
ab0010fa32 finish removing logout page 2015-03-31 13:56:49 -07:00
Min RK
1bc8d50261 add "Login with..." button
for custom authenticators that use external services (e.g. OAuth)
2015-03-31 13:56:49 -07:00
Min RK
24fd843c3c render login form at root
- redirect to server, if running (hub home, otherwise)
2015-03-31 13:56:49 -07:00
Min RK
cffdf89327 remove logout page
redirect to landing page, instead
2015-03-31 13:56:49 -07:00
Min RK
2e53de0459 Merge pull request #216 from minrk/allow_none
add missing allow_none=True in Spawner
2015-03-31 13:56:19 -07:00
Kyle Solan
80531341c0 switched app and singleuser to run under python3 by default 2015-03-31 19:54:13 +00:00
Min RK
94a3584620 Merge pull request #215 from minrk/cunicode-user
cast user to unicode
2015-03-30 13:19:51 -07:00
Min RK
12a1ec7f57 add missing allow_none=True in Spawner 2015-03-30 13:18:09 -07:00
Min RK
d13286606a cast user to unicode
allows numerical usernames without fighting with the config system.
2015-03-30 11:23:52 -07:00
Min RK
e39e6d2073 Merge pull request #211 from peterruppel/patch-1
Make the hub bind to address specified in hub_ip
2015-03-24 16:31:54 -07:00
Peter Ruppel
904c848bcc Make the hub bind to address specified in hub_ip
Bug: the hub binds to all interfaces and ignores the address given in 'hub_ip'. The 'address' parameter was missing in the call http_server.listen().
2015-03-24 20:15:29 +01:00
Min RK
038aae7e0a Merge pull request #208 from minrk/log-requests
customize request logging
2015-03-24 11:54:04 -07:00
Min RK
ba81bd4a01 Merge pull request #190 from minrk/bind_url
url logging
2015-03-24 11:52:33 -07:00
Min RK
36d62672df Merge pull request #207 from minrk/get-cookie-body
use `cookie_name/cookie_value` URL for cookie checking API
2015-03-24 09:48:17 -07:00
Min RK
ffd334b5ff Merge pull request #189 from jdavidheiser/master
set user shel from pw_shell

closes #189
closes #204
2015-03-23 17:55:44 -07:00
James Davidheiser
7701b82f58 replace using server shell with user shell
Per discussion on PR, replaced the heavy-handed setting of the shell environment variable through the server environment variables.
2015-03-23 17:55:16 -07:00
James Davidheiser
7ca96e5c6c spawner.py update for default shell
Update spawner.py to do a better job keeping the shell variable intact for terminals launched from within the notebook.
2015-03-23 17:55:16 -07:00
Min RK
3be33f2884 escape cookie value in auth uri
requests didn't seem to need it,
but I feel safer doing it explicitly.
2015-03-23 16:17:28 -07:00
Min RK
3d6a0c126f custom request logging
adapted from IPython

- demotes static page success, cache hits to debug-level
- removes auth info from log messages
- logs usernames for authenticated requests
2015-03-23 15:45:57 -07:00
Min RK
39a7feea72 use cookie_name/cookie_value URL for cookie checking API
instead of putting the value in the request body,
which is verboten for GET requests
2015-03-23 15:14:36 -07:00
Min RK
5529774c1d url logging
log the actual bind url (Server.bind_url),
rather than the connect url (Server.url),
which converts all-interfaces IPs to 'localhost'
2015-03-23 12:12:10 -07:00
Min RK
ffb2ba055a remove inappropriate argv in Hub.instance() 2015-03-23 12:06:07 -07:00
Min RK
2a2f9c0b67 Merge pull request #189 from minrk/timeout-spawn-error
better error messages for spawn failure
2015-03-23 11:55:15 -07:00
Min RK
3e89f45954 Merge pull request #197 from minrk/expire-token
expire login cookies
2015-03-23 11:51:08 -07:00
Min RK
fad5f5a61d Merge pull request #200 from quantopian/group_auth
DEV: Allow setting a whitelist_group on LocalAuthenticator.
2015-03-22 21:22:49 -07:00
Scott Sanderson
ed94c2d774 BUG: group_whitelist is a set, not a scalar. 2015-03-22 14:51:10 -04:00
Scott Sanderson
77c66d8b27 DEV: Make group/user whitelist mutually exclusive.
If group whitelist is provided, it takes precedence.
2015-03-19 10:42:55 -04:00
Scott Sanderson
33a4f31520 DEV: Allow setting a whitelist_group on LocalAuthenticator.
Any user in the group is considered in the whitelist.
2015-03-18 19:20:10 -04:00
Min RK
3fb2afc2bd expire login cookies
via tornado's max_age_days mechanism

default expiry is two weeks
2015-03-17 12:56:06 -06:00
Min RK
8787335b01 Merge pull request #196 from quantopian/invalidate-su-cookies
DEV: Periodically clear single-user server cookie cache.
2015-03-17 12:46:54 -06:00
Min RK
376ee29b12 Merge pull request #195 from minrk/chp-missing
better error when proxy command isn't found
2015-03-17 12:44:30 -06:00
Scott Sanderson
5e16e6f52f DEV: Periodically clear single-user server cookie cache.
Default is every 5 minutes.
2015-03-17 13:48:44 -04:00
Min RK
ce45fde74a better error when proxy command isn't found 2015-03-17 11:46:12 -06:00
Min RK
665edb6651 Merge pull request #194 from quantopian/handle-404-auth
BUG: Correctly handle 404 from hub.
2015-03-17 10:54:38 -06:00
Scott Sanderson
f98c8feaae BUG: Correctly handle 404 from hub.
Previously, the `if r.status_code == 404` branch was being trampled by a
later `if r.status_code > 400` branch.
2015-03-17 12:00:26 -04:00
Min RK
5100dd29c2 note when installing with npm 2015-03-14 14:32:31 -06:00
Min RK
40ae3a5821 Merge pull request #188 from blippy/master
Make jupyterhub and jupyterhub-singleuser executable and call python3
2015-03-13 12:57:58 -07:00
Min RK
da1fe54aee better error messages for spawn failure
Server started, but never became accessible:

> Failed to reach your server.
> Please try again later.
> Contact admin if the issue persists.

Server failed to start (errors in Spawner):

> Failed to start your server.
> Please contact admin.
2015-03-13 12:12:36 -07:00
Min RK
545739472e make default http timeout 30 seconds
some computers can be very slow to start
2015-03-13 12:04:07 -07:00
mark carter
36bb03dc3f Make jupyterhub and jupyterhub-singleuser executable and call python3 2015-03-13 09:01:00 +00:00
Min RK
61fa2d9ef2 Merge pull request #182 from Carreau/theme
Better Theme II
2015-03-08 18:26:42 -07:00
Matthias Bussonnier
301560b6f8 use bootstrap variables 2015-03-08 18:06:43 -07:00
Matthias Bussonnier
5bd649829a remove dropdown 2015-03-08 17:44:46 -07:00
Matthias Bussonnier
eccf2fe5be Make theme closer to notebook.
improvement of admin page split
edit/delete in their own column
2015-03-08 17:39:46 -07:00
Min RK
e82683d14f remove 'workaround' that didn't workaround anything 2015-03-08 17:25:53 -07:00
Min RK
2455680ab8 cleanup bizarre login html 2015-03-08 17:21:04 -07:00
Min RK
827f694589 simplify login css
use mixins, remove lots of unnecessary CSS
2015-03-08 17:21:04 -07:00
Min RK
fa7e230b6e Merge pull request #56 from Carreau/master
Implementation of login page design
2015-03-08 17:20:23 -07:00
Min RK
42a8094b20 add links to wiki auth/spawner lists
from the docs
2015-03-07 17:08:13 -08:00
Min RK
a898063b83 back to dev 2015-03-07 16:48:20 -08:00
Matthias Bussonnier
c0b67770e4 make color and gradient more logo-like 2015-03-06 12:25:07 -08:00
Matthias Bussonnier
791e527695 do not change brand primary, plus rework login form 2015-03-06 12:06:45 -08:00
Matthias Bussonnier
a6b79780b3 compute gradient 2015-03-03 17:14:58 -08:00
Matthias Bussonnier
25bcb6ede4 fix bootstrap bug 2015-03-03 17:09:30 -08:00
Bussonnier Matthias
839bd79bbd Implementation of login page design 2015-03-03 16:53:35 -08:00
75 changed files with 4549 additions and 885 deletions

4
.coveragerc Normal file
View File

@@ -0,0 +1,4 @@
[run]
omit =
jupyterhub/tests/*
jupyterhub/singleuser.py

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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/

View File

@@ -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:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter)
[![Build Status](https://travis-ci.org/jupyter/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyter/jupyterhub)
[![Circle CI](https://circleci.com/gh/jupyter/jupyterhub.svg?style=shield&circle-token=b5b65862eb2617b9a8d39e79340b0a6b816da8cc)](https://circleci.com/gh/jupyter/jupyterhub)
[![Documentation Status](https://readthedocs.org/projects/jupyterhub/badge/?version=latest)](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:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter)
and you may participate in development discussions or get live help on Gitter:
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](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
View File

@@ -0,0 +1,11 @@
machine:
services:
- docker
dependencies:
override:
- ls
test:
override:
- docker build -t jupyter/jupyterhub .

View File

@@ -1,2 +1,5 @@
-r requirements.txt -r requirements.txt
pytest codecov
pytest-cov
pytest>=2.8
notebook

192
docs/Makefile Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
-r ../requirements.txt
sphinx
recommonmark==0.4.0

259
docs/rest-api.yml Normal file
View 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
View 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
View File

@@ -0,0 +1,14 @@
.. _api-index:
####################
The JupyterHub API
####################
:Release: |release|
:Date: |today|
.. toctree::
auth
spawner
user

View 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
View 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.

View File

@@ -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
View 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
View 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

View 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

View File

@@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

80
docs/source/index.rst Normal file
View 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
View 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:
![spawn-form](images/spawn-form.png)
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

View File

@@ -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

View 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

View File

@@ -1,2 +1,2 @@
from .version import * from .version import version_info, __version__

View File

@@ -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),
] ]

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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:

View File

@@ -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)

View File

@@ -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),
] ]

View File

@@ -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)

View File

@@ -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),
] ]

View File

@@ -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))

View File

@@ -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"""

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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')

View File

@@ -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

View 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'},
}

View File

@@ -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)

View File

@@ -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])

View 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']

View File

@@ -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
View 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)
)

View File

@@ -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

View File

@@ -5,7 +5,7 @@
version_info = ( version_info = (
0, 0,
1, 4,
0, 0,
) )

5
readthedocs.yml Normal file
View File

@@ -0,0 +1,5 @@
name: jupyterhub
type: sphinx
requirements_file: docs/requirements.txt
python:
version: 3

View File

@@ -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
View 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
View 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()

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -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();
} }

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}
} }

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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()}}

View File

@@ -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 %}

View File

@@ -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>

View 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 %}