Compare commits

..

379 Commits
0.2.0 ... 0.6.1

Author SHA1 Message Date
Min RK
2985562c2f release 0.6.1 2016-05-04 14:08:39 +02:00
Min RK
754f850e95 changelog for 0.6.1 2016-05-04 14:08:39 +02:00
Min RK
dccb85d225 plural add-users ids 2016-05-04 13:57:16 +02:00
Min RK
a0e401bc87 Merge pull request #551 from minrk/proxy-error
Serve proxy error pages from the Hub
2016-05-04 12:34:10 +02:00
Min RK
c6885a2124 Merge pull request #552 from minrk/poll-and-notify
notice dead servers more often
2016-05-04 12:33:09 +02:00
Min RK
7528fb7d9b notice dead servers more often
call poll_and_notify to ensure triggering of dead-server events in a few places:

- `/hub/home` page view
- user start and stop API endpoints

This should avoid the failure to stop a server that's died uncleanly because the server hasn't noticed yet
2016-05-04 11:07:28 +02:00
Carol Willing
e7df5a299c Merge pull request #556 from minrk/shutdown-all
Add Stop All button to admin page
2016-05-03 05:58:41 -07:00
Min RK
ff997bbce5 Add Stop All button to admin page
for stopping all single-user servers at once
2016-05-03 13:25:12 +02:00
Min RK
1e21e00e1a return status from poll_and_notify
allows calling it directly
2016-04-27 14:28:23 +02:00
Min RK
77d3ee98f9 allow logo_url in template namespace to set the logo link 2016-04-27 14:06:51 +02:00
Min RK
1f861b2c90 server proxy error pages from the Hub 2016-04-27 14:06:29 +02:00
Carol Willing
14a00e67b4 Merge pull request #550 from daradib/typo
Fix docs typo for Spawner.disable_user_config
2016-04-26 15:28:02 -07:00
Dara Adib
14f63c168d Fix docs typo for Spawner.disable_user_config 2016-04-26 11:36:48 -07:00
Kyle Kelley
e70dbb3d32 Merge pull request #549 from minrk/optional-statsd
Make statsd an optional dependency
2016-04-26 07:46:28 -05:00
Min RK
b679275a68 remove unneeded codecov.yml
codecov team config suffices
2016-04-26 13:44:29 +02:00
Min RK
0c1478a67e Make statsd an optional dependency
only import it if it's used
2016-04-26 13:37:39 +02:00
Min RK
d26e2346a2 Merge pull request #548 from minrk/jupyterhub-urls
fix a few more jupyter->jupyterhub URLs
2016-04-26 12:41:19 +02:00
Min RK
9a09c841b9 Merge pull request #547 from minrk/disable-codecov-comments
disable codecov PR comments
2016-04-26 12:41:02 +02:00
Min RK
f1d4f5a733 fix a few more jupyter->jupyterhub URLs
in README
2016-04-26 11:58:27 +02:00
Min RK
d970dd4c89 disable CodeCov PR comments
The've removed web app config, in favor of codecov.yml,
discarding our existing config,
which means coverage reports are showing up in most Jupyter PRs now.
2016-04-26 11:55:52 +02:00
Min RK
f3279bf849 Merge pull request #544 from rafael-ladislau/master
Fix multiple windows logout error
2016-04-26 11:41:53 +02:00
Rafael Ladislau
db0878a495 Fix multiple windows logout error
When you have two JupyterHub windows and log out successfully in one of them. If you try to click the logout button in the other window, you will receive a 500 error.

It happened because there were operations being done in a None user object.
2016-04-25 13:31:39 -04:00
Min RK
c9b1042791 back to dev 2016-04-25 14:34:15 +02:00
Min RK
cd81320d8f push tags on circleci 2016-04-25 14:25:34 +02:00
Min RK
3046971064 release 0.6 2016-04-25 14:10:29 +02:00
Min RK
30498f97c4 Merge pull request #543 from robnagler:master
Allow jupyterhub-singleuser to run on python 2 install

closes #543
2016-04-25 11:35:40 +02:00
robnagler
d9d68efa55 run with default python, which might be python 2 2016-04-25 11:31:17 +02:00
Min RK
4125dc7ad0 Merge pull request #542 from willingc/doc-addition
Add troubleshooting documentation for 500 issue
2016-04-22 15:23:28 +02:00
Carol Willing
13600894fb Changed link re: Min's tip 2016-04-22 06:11:53 -07:00
Carol Willing
1b796cd871 Add links 2016-04-22 05:37:30 -07:00
Carol Willing
e7889dc12e Add 500 error to troubleshooting docs 2016-04-22 05:36:15 -07:00
Carol Willing
244a3b1000 Merge pull request #541 from minrk/cookie-referer
check referer only if there is a valid user cookie
2016-04-22 05:03:05 -07:00
Carol Willing
05dfda469f Merge pull request #540 from minrk/0.6
Changelog for 0.6
2016-04-22 04:21:44 -07:00
Min RK
6b19ee792d check referer only if there is a valid user cookie
avoids misleading "Blocking Cross Origin..." message
when there's no logged-in user for API requests.
2016-04-22 13:16:13 +02:00
Min RK
ace38d744a Changelog for 0.6 2016-04-22 12:50:49 +02:00
Min RK
56a5ed8c87 Merge pull request #539 from minrk/unused-email
add ignored -e arg to docker login
2016-04-22 10:50:15 +02:00
Min RK
60e8a76476 add ignored -e arg to docker login
doesn't appear to be needed on more recent docker
2016-04-22 10:24:40 +02:00
Min RK
552800ceb7 add sec doc
reviewed on security list
2016-04-22 10:20:18 +02:00
Carol Willing
7dd1900f5f Merge pull request #521 from minrk/docker-onbuild
Move docker onbuild step to jupyterhub-onbuild
2016-04-21 18:26:43 -07:00
Min RK
35c261d0ed better Dockerfile comments from Carol 2016-04-21 21:32:36 +02:00
Min RK
fa34ce64b7 include dockerfiles in manifest 2016-04-21 13:50:35 +02:00
Min RK
f0504420a9 move docker onbuild to directory 2016-04-21 13:50:27 +02:00
Min RK
8666f3a46c push onbuild image to docker hub with circle-ci 2016-04-21 13:38:15 +02:00
Carol Willing
60d6019cf7 Merge pull request #534 from jupyterhub/willingc-patch-1
Fix post move links to jupyterhub org in README
2016-04-20 20:05:39 -07:00
Carol Willing
173daeeb09 Fix post move links to jupyterhub org in README 2016-04-20 20:00:18 -07:00
Carol Willing
cf988dca4d Merge pull request #531 from minrk/extra-log-file-doc
[DOC] Note that extra_log_file only affects Hub's logs
2016-04-20 06:00:01 -07:00
Min RK
ffc2faabf7 [DOC] Note that extra_log_file only affects Hub's logs
not single-user server logs, or anything else.
2016-04-20 14:45:22 +02:00
Min RK
9fed0334c8 jupyterhub path in dockerfiles 2016-04-20 14:43:25 +02:00
Min RK
8b61eb7347 install from miniconda 4.0.5
- checksum miniconda installer
- move jupyterhub src to /src/jupyterhub
2016-04-19 13:21:25 +02:00
Min RK
9cdda101c7 Move onbuild step to jupyterhub-onbuild
Removes onbuild from from jupyter/jupyterhub image,
though it remains incomplete and will not run without a config file.
2016-04-19 13:21:24 +02:00
Carol Willing
f3bbca80ea Merge pull request #528 from minrk/test-cookie-secret
exercise cookie secret loading in tests
2016-04-19 04:01:36 -07:00
Carol Willing
ce30f28449 Merge pull request #527 from minrk/polish-520
polish cookie-secret PR
2016-04-19 03:56:41 -07:00
Min RK
6cb58c17e7 exercise cookie secret loading in tests 2016-04-19 11:15:48 +02:00
Min RK
183e244490 polish cookie-secret PR
- fix a couple of typos
- use ValueError instead of assert to ensure error is raised even when Python optimizes-out asserts
2016-04-19 10:15:10 +02:00
Min RK
d5cd5115a5 Merge pull request #520 from robnagler/master
cookie_secret file must be base64
2016-04-19 10:10:58 +02:00
robnagler
bbd3b22490 incorrect log call in previous checkin 2016-04-18 16:25:03 +00:00
robnagler
e02daf01ad Fix jupyter/jupyterhub#520: exit if any errors parsing file; Also, fix abstraction use of getenv/os.environ (use one or the other, not both) 2016-04-18 15:35:31 +00:00
robnagler
af1e253f8a Fix jupyter/jupyterhub#522 2016-04-18 15:16:01 +00:00
Min RK
491da69994 typo 2016-04-18 12:51:35 +02:00
Min RK
0737600d3c Merge pull request #515 from proversity-org/master
Post handler for  requesting authorization tokens; authenticated via form.
2016-04-18 11:23:24 +02:00
dominic
c7f542e79e Add tests for form based token generation 2016-04-18 10:27:38 +02:00
robnagler
21213c97c6 cookie_secret file is decoded by binascii.a2b_base64 so need to document it must be Base64. Added better doc for other values, and included description of "cookie_secret" parameter as well 2016-04-17 23:35:06 +00:00
Carol Willing
b36cd92ae6 Merge pull request #517 from minrk/load-tokens
allow pre-loading API tokens from config
2016-04-15 06:49:40 -07:00
Min RK
094ac451c7 Don't allow bad tokens to create tokens in the db 2016-04-15 12:42:52 +02:00
Min RK
fa4b666693 allow pre-loading API tokens from config
This is the first small part of easing the pain of services,
which is generating the API tokens,
and used to require initializing the JupyterHub database.
2016-04-14 16:45:40 +02:00
Carol Willing
ce9dc2093c Merge pull request #514 from minrk/docker-readme
revisions to docker notes
2016-04-14 07:05:02 -07:00
dominic
9fd97a8d63 Keep line spacing consistent. Don't do anything if authenticator not defined. 2016-04-14 15:39:26 +02:00
Min RK
2261a0e21d revisions to docker notes
- link to Docker docs on volumes
- name container `jupyterhub`
- wording
2016-04-14 15:35:58 +02:00
dominic
a7a1c32a03 Add post handler for form based auth 2016-04-14 09:32:42 +02:00
Matthias Bussonnier
dfd01bbf5f Merge pull request #503 from minrk/disable-npm-progress
disable npm progress when installing
2016-04-08 17:36:02 -07:00
Min RK
b11a5be781 disable npm progress when installing
apparently faster, but should also fix unicode errors
2016-04-08 16:35:23 -07:00
Kyle Kelley
8b6950055b Merge pull request #501 from minrk/set-login-at-root-redirect
set login cookie when redirecting
2016-04-06 12:35:50 -05:00
Min RK
e8a298be00 set login cookie when redirecting
should avoid one possible redirect-loop case when the single-user cookie is invalid, but the Hub cookie is valid.
2016-04-06 10:18:23 -07:00
Min RK
69f24acac2 Merge pull request #499 from yuvipanda/statsd
Emit metrics via statsd
2016-04-05 09:23:20 -07:00
YuviPanda
9ffebd0c5e Send metrics about various redirects from User spawning 2016-04-01 14:05:02 -07:00
YuviPanda
2dd3d3c448 Send timing info about spawner success / failure 2016-04-01 10:20:37 -07:00
YuviPanda
4644e7019e Send metrics about running and active users
Uses the standard user last-updated activity callback
2016-04-01 10:20:37 -07:00
YuviPanda
5a15d7a219 Actually start the timer 2016-04-01 10:20:37 -07:00
YuviPanda
788129da12 Send metrics for login and logout actions 2016-04-01 10:20:37 -07:00
YuviPanda
cac5175c9b Send CSP metrics to statsd 2016-04-01 10:20:37 -07:00
YuviPanda
80556360ac Add statsd to the base request handler 2016-04-01 10:20:37 -07:00
YuviPanda
3dca0df55f Add statsd to the base JupyterHub app
Not actually emitting any metrics yet
2016-04-01 10:20:37 -07:00
Min RK
62a5e9dbce Merge pull request #497 from yuvipanda/env-callable
Allow environment config values to be callable
2016-04-01 09:57:52 -07:00
YuviPanda
45fcdc75c0 Add docs about callables in environment configurable 2016-03-31 23:44:08 -07:00
Min RK
f1bdf6247a Merge pull request #500 from yuvipanda/fix-url-encoding-4
Use User.url instead of constructing it manually
2016-03-31 22:15:58 -07:00
YuviPanda
80932a51f4 Use User.url instead of constructing it manually
This fixes issues with URL encoding when redirecting users to
their own notebook instances
2016-03-31 17:28:33 -07:00
Min RK
c8774c44d4 Merge pull request #498 from yuvipanda/statsd-configurable
Mark statsd_prefix as configurable as well
2016-03-31 15:08:10 -07:00
YuviPanda
bf2629450c Mark statsd_prefix as configurable as well 2016-03-31 13:46:37 -07:00
YuviPanda
705ff78715 Allow environment config values to be callable
This allows deployments to configure environment variables
that need to be different for each user / container (such as
credentials for various services, etc).
2016-03-31 11:52:53 -07:00
Min RK
a13119a79f Merge pull request #496 from yuvipanda/statsd
Allow specifying statsd host/port/prefix info
2016-03-31 11:18:09 -07:00
YuviPanda
6932719e4e Convert port into string (so that .join works) 2016-03-31 10:32:49 -07:00
YuviPanda
68a750fc7a Use 'Integer' rather than 'Int' for config traitlet 2016-03-30 19:04:57 -07:00
YuviPanda
c6d05d0840 Allow specifying statsd host/port/prefix info
Currently only passes it through to CHP. This is needed
for the cases when JupyterHub spawns and maintains CHP.
2016-03-30 18:59:32 -07:00
Carol Willing
2bbfd75f4d Merge pull request #495 from Carreau/add-import
Import warnings, used on line 215, not imported.
2016-03-29 15:52:39 -07:00
Matthias Bussonnier
26f0e8ea5c Import warnings, used on line 215, not imported. 2016-03-29 15:36:22 -07:00
Carol Willing
552e5caa11 Merge pull request #494 from jupyter/Codecov-badge
Add codecov Badge.
2016-03-29 15:23:54 -07:00
Matthias Bussonnier
7753187e51 Add codecov Badge. 2016-03-29 15:04:17 -07:00
Carol Willing
bddadc7522 Merge pull request #493 from minrk/traitlets-4-1-again
use traitlets 4.1 APIs
2016-03-29 14:51:51 -07:00
Min RK
195eea55f3 log.warning 2016-03-29 09:22:32 -07:00
Min RK
7a2794af7c use traitlets-4.1 observe/default decorators 2016-03-27 10:41:36 -07:00
Min RK
fa48620076 use traitlets-4.1 .tag(config=True) API 2016-03-27 10:29:36 -07:00
Min RK
e4cfe01c4a require traitlets 4.1 2016-03-27 10:21:41 -07:00
Carol Willing
b35e506220 Merge pull request #479 from minrk/config-env
Make Spawner.env configurable
2016-03-24 07:59:44 -07:00
Carol Willing
dd3ed1bf75 Merge pull request #490 from minrk/disable-pam-session
Allow disabling PAM sessions
2016-03-24 07:57:00 -07:00
Min RK
40368b8f55 Allow disabling PAM sessions
it's often buggy and rarely necessary,
so allow it to be disabled when it's causing problems.

It's still on by default for backward-compatibility,
though maybe it shouldn't be.
2016-03-23 23:24:54 +01:00
Min RK
d0f1520642 Add Spawner.environment configurable
instead of making existing Spawner.env configurable

Spawner.env is deprecated
2016-03-22 13:48:26 +01:00
Carol Willing
28c8265c3d Merge pull request #487 from minrk/fix-failed-login-for-none
Fix 'failed login for None' message
2016-03-21 04:51:20 -07:00
Min RK
1d1a8ba78b Fix 'failed login for None' message
on failed login, get username from form data, not the guaranteed-None return value of authenticate
2016-03-21 12:01:31 +01:00
Min RK
a1c764593c travis_retry tests
to hide intermittent failures and enable laziness
2016-03-15 10:37:03 +01:00
Min RK
06902afa2d Merge pull request #481 from willingc/issue-417
Add additional documentation on --no-SSL option
2016-03-15 10:12:18 +01:00
Min RK
6d46f10cfa Merge pull request #480 from willingc/issue-458
Update the configuration section of docs
2016-03-15 10:11:54 +01:00
Carol Willing
b71f34eb3c Fix transposed version number 2016-03-14 16:57:12 -07:00
Carol Willing
11df935f34 Fix awkward wording 2016-03-14 16:54:04 -07:00
Carol Willing
19b6468889 Add no-SSL option to docs 2016-03-14 16:48:49 -07:00
Carol Willing
d2dddd6c82 Update the configuration section of docs, add example 2016-03-14 16:21:24 -07:00
Min RK
5d140fb889 Merge pull request #478 from willingc/readme-docker
Update README re: docker image contents
2016-03-11 22:24:27 +01:00
Matthias Bussonnier
2bf8683905 Merge pull request #477 from willingc/doc-sphinx
Use latest version of Sphinx to fix RTD "Edit on GitHub"
2016-03-11 10:46:52 -08:00
Carol Willing
2dba7f4f61 Update README re: docker image contents 2016-03-11 10:05:13 -08:00
Carol Willing
2820ba319f Update sphinx version for md on rtd 2016-03-11 07:55:49 -08:00
Min RK
be7a627c11 Make Spawner.env configurable
moves `_env_default` logic to `get_env`,
so that `Spawner.env` can be safely configurable
2016-03-11 12:34:49 +01:00
Matthias Bussonnier
2cb1618937 Merge pull request #467 from minrk/add-user-more-often
Call `add_user` more often
2016-03-10 14:45:56 -08:00
Min RK
c9e0c5fe04 Merge pull request #474 from minrk/user.url
allow user.url to be accessed without the server running
2016-03-10 10:28:11 +01:00
Min RK
922956def2 allow user.url to be accessed without the server running
Reduces the number of different ways we need to build the same URLs.
2016-03-09 09:30:50 +01:00
Min RK
c6c699ea89 Merge pull request #472 from yuvipanda/fix-user-encoding
Use encoded URL when redirecting user notebooks
2016-03-09 09:20:43 +01:00
YuviPanda
e0219d0363 Use encoded URL when redirecting user notebooks
Otherwise it breaks for usernames that have url unsafe
characters.
2016-03-08 18:41:35 -08:00
Matthias Bussonnier
f7dab558e4 Merge pull request #468 from minrk/clean-the-pool
set default pool_recycle if using mysql
2016-03-08 11:28:59 -08:00
Min RK
74e558dad2 set default pool_recycle if using mysql 2016-03-08 10:58:18 +01:00
Min RK
96269fac0f Call add_user more often
- Ensures add_user is called as part of startup *for all users*.
  This was previously only true for users not already in the db.
- Normalize usernames in whitelist and admin sets
- Call add_user on new users logged in when there is no whitelist.
2016-03-08 10:49:02 +01:00
Min RK
a0501c6ee4 set patch version to 0 on release 2016-03-08 09:55:44 +01:00
Min RK
ea2ed75ab2 back to dev 2016-03-08 09:00:41 +01:00
Min RK
fc6435825c release 0.5.0 2016-03-08 08:57:33 +01:00
Min RK
b3ab48eb68 Merge pull request #463 from minrk/moar-coverage
Increase some test coverage
2016-03-07 17:13:20 +01:00
Carol Willing
a212151c09 Merge pull request #461 from minrk/0.5
0.5 changelog
2016-03-07 08:07:19 -08:00
Min RK
67ccfc7eb7 increase some test coverage 2016-03-07 16:13:57 +01:00
Min RK
9af103c673 fixes for handling failed chdir in spawners 2016-03-07 15:12:30 +01:00
Min RK
82643adfb6 stop_pending also counts as not running 2016-03-07 14:27:40 +01:00
Min RK
74df94d15a 0.5 changelog 2016-03-07 13:54:40 +01:00
Min RK
da1b9bdd80 Merge pull request #460 from yuvipanda/mysql-fix
Add lengths to all Unicode() columns
2016-03-07 10:36:17 +01:00
Min RK
18675ef6df Merge pull request #453 from minrk/timeout-in-is-up
use the same connection check everywhere
2016-03-07 10:35:12 +01:00
YuviPanda
bf9dea5522 Add lengths to all Unicode() ones
- Otherwise does not work with MySQL
- Change JSONDict to be TEXT (Unbounded) rather than VARCHAR.
  This makes most sense, since you can't index these anyway.
- The 'ip' field in Server is set to 255, since that is the
  max allowed length of DNS entries.
- Most of the rest of the Unicodes have approximately high
  values that most people should not mostly run into
  (famous last words).
2016-03-06 18:26:25 -08:00
Min RK
62e30c1d79 Merge pull request #457 from shreddd/default_url
Enable default_url to pass in to notebook server
2016-03-06 10:33:52 +01:00
shreddd
1316196542 Update spawner.py
type
2016-03-05 12:24:39 -08:00
Shreyas Cholia
1a377bd03a comment on default_url being used with notebook_dir 2016-03-05 12:16:10 -08:00
Shreyas Cholia
66a99ce881 Add support for default_url 2016-03-05 12:05:58 -08:00
shreddd
481debcb80 Merge pull request #1 from jupyter/master
sync master
2016-03-05 12:04:09 -08:00
Carol Willing
03c25b5cac Merge pull request #452 from minrk/redundant-use-subdomain
remove redundant use_subdomains
2016-03-05 11:52:43 -08:00
Carol Willing
26c060d2c5 Merge pull request #456 from willingc/readme-clarify
Add minor clarification to README
2016-03-05 10:57:13 -08:00
Carol Willing
7ff42f9b55 Add @betatim's suggested wording 2016-03-05 10:43:45 -08:00
Carol Willing
a35d8a6262 Add minor clarification 2016-03-05 10:14:44 -08:00
Carol Willing
8f39e1f8f9 Merge pull request #455 from betatim/readme-fix
README uses two different names for docker container
2016-03-05 10:08:34 -08:00
Tim Head
ff19b799c4 container -> cont for consistency 2016-03-05 09:19:15 +01:00
Kyle Kelley
e547949aee Merge pull request #433 from minrk/disable-user-config
allow disabling user configuration of single-user servers
2016-03-04 09:57:45 -06:00
Min RK
31be00b49f failure to connect may be a timeout 2016-03-04 16:28:57 +01:00
Min RK
4533d96002 use the same connection check everywhere
avoids inconsistencies in error handling
2016-03-04 16:28:57 +01:00
Min RK
7f89f1a2a0 expose disable_user_config as Spawner.disable_user_config 2016-03-04 14:41:40 +01:00
Min RK
aed29e1db8 Simplify filter to exclude config in the home directory 2016-03-04 11:43:45 +01:00
Min RK
49bee25820 allow disabling user configuration of single-user servers 2016-03-04 11:43:45 +01:00
Min RK
838c8eb057 Merge pull request #448 from daradib/redirect
Redirect requests to logged in user
2016-03-04 11:15:56 +01:00
Min RK
be5860822d remove redundant use_subdomains
non-empty subdomain_host is enough
2016-03-04 11:11:41 +01:00
Dara Adib
5a10d304c9 Redirect user to login page when not logged in 2016-03-02 16:55:33 -08:00
Dara Adib
fdd3746f54 Add test for user redirect 2016-03-02 16:18:02 -08:00
Dara Adib
4d55a48a79 Redirect requests to logged in user
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
redirect her to /user/alice/notebooks/mynotebook.ipynb.
Currently, such requests get stuck in a redirect loop because
the request will be redirected to login page with a next parameter
that when followed is again redirected.

When notebook_dir is consistent across users, this will allow
users to share notebook URLs. Fixes #424.
2016-03-02 16:15:50 -08:00
Min RK
b2ece48239 reverse arguments in check_routes 2016-03-01 19:42:55 +01:00
Kyle Kelley
6375ba30b7 Merge pull request #445 from minrk/check-routes-pending
Don't add users with spawn_pending to the proxy
2016-03-01 09:19:42 -06:00
Min RK
f565f8ac53 Don't add users with spawn_pending to the proxy
check_routes checks for missing routes for running users.
This is meant for when the proxy has been relaunched outside the Hub.

If spawners are slow to start, it's possible for check_routes to fire in the middle of spawning,
triggering addition of the user's server (which has no defined location yet) to the proxy before it's up.
If the spawning fails, the route will remain indefinitely (because it never should have been added in the first place), and the user will see 503 until their server is launched manually again.

Checking `spawn_pending` in user.running prevents this.
2016-03-01 15:18:51 +01:00
Kyle Kelley
5ec05822f1 Merge pull request #436 from minrk/subdomains
allow running single-user servers on subdomains
2016-02-28 09:49:45 -06:00
Min RK
335b47d7c1 include protocol in subdomain_host
makes everything easier, and tests are passing with and without subdomains (yay!)
2016-02-28 11:12:41 +01:00
Min RK
f922561003 Tests are passing with subdomains 2016-02-26 17:32:55 +01:00
Min RK
79df83f0d3 Allow getting users by name 2016-02-26 17:32:55 +01:00
Min RK
29416463ff proxy needs user dict, which has proxy path
this won't be needed if/when I make a schema change, where domain is included in the Server table.
2016-02-26 17:32:55 +01:00
Min RK
dd2e1ef758 turn off subdomains by default 2016-02-26 17:32:55 +01:00
Min RK
a9b8542ec7 pass hub's host to single-user servers via hub_host 2016-02-26 17:32:54 +01:00
Min RK
a4ae2ec2d8 consolidate cookie setting in _set_user_cookie 2016-02-26 17:32:54 +01:00
Min RK
b54bfad8c2 [WIP]: allow running single-user servers on subdomains
relies on CHP's host-based routing (a feature I didn't add!)

requires wildcard DNS and wildcard SSL for a proper setup

still lots to workout and cleanup in terms of cookies and where to use host, domain, path, but it works locally.
2016-02-26 17:32:54 +01:00
Min RK
724bf7c4ce Merge pull request #441 from jupyter/revert-440-master
Revert "Do not consider `@` character url-safe"
2016-02-26 09:06:46 +01:00
Kyle Kelley
fccc954fb4 Merge pull request #442 from minrk/never-poll-before-start-is-done
avoid calling Spawner.poll during Spawner.start
2016-02-25 08:20:35 -06:00
Kyle Kelley
74385a6906 Merge pull request #443 from minrk/catch-options-from-form
catch exceptions in options_from_form
2016-02-25 08:19:16 -06:00
Min RK
dd66fe63c0 catch exceptions in options_from_form
Allows form validation to be implemented in options_from_form, as well as start.
2016-02-25 12:02:23 +01:00
Min RK
e74934cb17 avoid calling Spawner.poll during Spawner.start
moves `spawn_pending` flag to only around start, not the HTTP wait.

Some Spawners may not know how to poll until start has finished (DockerSpawner).
Let's not require that they do.
2016-02-25 10:13:51 +01:00
Min RK
450281a90a Revert "Do not consider @ character url-safe" 2016-02-25 09:04:25 +01:00
Kyle Kelley
6e7fc0574e Merge pull request #440 from ResearchComputing/master
Do not consider `@` character url-safe
2016-02-24 23:58:45 -06:00
Jonathon Anderson
fc49aac02b Do not consider `@' character url-safe
Usernames that have an `@'-separated domain component
break JupyterHub when the server expects to see query
strings that contain an `@', when browsers and other
clients send `%40'.
2016-02-24 16:48:23 -07:00
Kyle Kelley
097d883905 Merge pull request #435 from minrk/debug-no-server
add debug logging for adding users with no running server
2016-02-20 06:04:12 -08:00
Min RK
cb55118f70 add debug logging for adding users with no running server
in check_routes, it has been reported that users without a running server are attempted to be added.

So something is wrong, either in sqlalchemy or my understanding of what it does (likely the latter),
because a filter for users with a non-None server is returning at least one result whose server is None.
2016-02-20 14:22:50 +01:00
Carol Willing
2a3c87945e Merge pull request #434 from rgbkrk/ssl
Don't let the default include `--no-ssl`.
2016-02-18 16:48:06 -08:00
Kyle Kelley
2b2aacedc6 Don't let the default include --no-ssl. 2016-02-18 16:27:53 -08:00
Kyle Kelley
8ebec52827 Merge pull request #431 from ObiWahn/master
Update README.md
2016-02-18 16:25:56 -08:00
Jan Christoph Uhde
1642cc30c8 fix: run vs exec and split sentence 2016-02-19 00:13:02 +01:00
Kyle Kelley
1645d8f0c0 Merge pull request #432 from minrk/no-port-retries
disable port_retries in single-user server
2016-02-18 06:40:51 -08:00
Min RK
8d390819a1 disable port_retries in single-user server
since Spawners won't notice that the server has started somewhere other than where it was asked to
2016-02-18 09:03:45 +01:00
Jan Christoph Uhde
c7dd18bb03 Update README.md 2016-02-16 22:58:27 +01:00
Min RK
84b7de4d21 set x bit on jupyterhub-singleuser 2016-02-15 21:50:55 +01:00
Carol Willing
161df53143 Merge pull request #426 from takluyver/docs-intro
Add overview to landing page
2016-02-13 11:12:35 -08:00
Thomas Kluyver
1cfd6cf12e Fix grammaros 2016-02-13 18:18:23 +00:00
Thomas Kluyver
d40dcc35fb Reword intro 2016-02-13 16:44:41 +00:00
Thomas Kluyver
a570e95602 Add my overview to intro
Closes gh-425
2016-02-13 15:29:08 +00:00
Thomas Kluyver
e4e43521ee Close code block 2016-02-13 15:28:37 +00:00
Min RK
1b2c21a99c Merge pull request #423 from minrk/custom-logo
allow overriding logo
2016-02-11 15:03:02 +01:00
Min RK
e28eda6386 exercise some static file handlers in tests 2016-02-09 15:38:44 +01:00
Min RK
39c171cce7 allow overriding logo
by specifying JupyterHub.logo_file

also ensures single-user server always has the same logo image as the Hub
2016-02-09 15:38:34 +01:00
Min RK
c81cefd768 Merge pull request #372 from minrk/require-notebook-4
drop support for single-user server from IPython 3.x
2016-02-09 14:42:12 +01:00
Min RK
325f137265 Merge pull request #421 from Fokko/add-docker-label
Added label to dockerfile for referencing
2016-02-09 14:41:48 +01:00
Fokko Driesprong
1ae795df18 Changed domain of the label to .org 2016-02-09 14:16:50 +01:00
Fokko Driesprong
2aacd5e28b Added label to dockerfile for referencing 2016-02-08 17:16:20 +01:00
Kyle Kelley
6e1425e2c0 Merge pull request #417 from minrk/require-confirm-insecure
require confirmation for JupyterHub to run without SSL
2016-02-05 19:27:37 -06:00
Carol Willing
010db6ce72 Merge pull request #416 from willingc/doc-warn
Add more prominent message for https
2016-02-04 14:21:52 -08:00
Min RK
ce8d782220 no-ssl in changelog 2016-02-04 23:00:54 +01:00
Min RK
90c2b23fc0 require confirmation for JupyterHub to run without SSL
ensures folks deploying JupyterHub on HTTP have been told what's up.
2016-02-04 23:00:54 +01:00
Carol Willing
32685aeac1 Add more prominent message for https 2016-02-04 13:42:13 -08:00
Min RK
01c5608104 update version requirements in README 2016-02-04 22:41:18 +01:00
Min RK
a35f6298f0 drop support for single-user server from IPython 3.x 2016-02-04 22:40:44 +01:00
Min RK
8955d6aed4 Merge pull request #411 from minrk/one-two-seven
use 127.0.0.1 instead of localhost
2016-02-04 20:37:09 +01:00
Min RK
cafbf8b990 back to dev 2016-02-03 21:05:48 +01:00
Min RK
7837a9cf68 release 0.4.1 2016-02-03 21:04:32 +01:00
Min RK
65a019e05b Merge pull request #413 from minrk/login_url
Restore /login handler
2016-02-03 21:00:03 +01:00
Min RK
f2014c5687 note that login/logout should always be registered 2016-02-03 20:54:01 +01:00
Min RK
109c315336 changelog for 0.4.1 2016-02-03 16:55:25 +01:00
Min RK
941fc7e627 restore /login page
erroneously removed in 0.4
2016-02-03 16:52:43 +01:00
Min RK
f626d2f6e5 use 127.0.0.1 instead of localhost
localhost can cause some issues on badly behaved or misconfigured systems,
and 127 seems simpler.
2016-02-03 10:30:09 +01:00
Min RK
80215f6b3c Merge pull request #407 from willingc/doc-proxy
Add doc details for #406
2016-02-02 09:06:35 +01:00
Carol Willing
84916062f0 Edit per @minrk and added troubleshooting 2016-02-01 14:17:14 -08:00
Carol Willing
641154bf06 Add doc details for #406 2016-02-01 11:47:08 -08:00
Min RK
14b0dbde0e Merge pull request #405 from willingc/doc-link
Update documentation link to source code
2016-02-01 19:58:08 +01:00
Carol Willing
cd85766441 Update link to source code 2016-02-01 08:42:49 -08:00
Min RK
6c072bdb3d nonempty long_description
avoids dumping README.md garbage onto PyPI
2016-02-01 11:05:58 +01:00
Min RK
35f080458e Upload with twine 2016-02-01 10:41:51 +01:00
Min RK
feac4f6bc4 Changelog for 0.4 2016-02-01 10:41:51 +01:00
Min RK
1bbabbb989 back to dev 2016-02-01 10:37:46 +01:00
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
69 changed files with 5162 additions and 1663 deletions

View File

@@ -3,3 +3,4 @@ bench
jupyterhub_cookie_secret
jupyterhub.sqlite
jupyterhub_config.py
node_modules

2
.gitignore vendored
View File

@@ -4,6 +4,8 @@ node_modules
.DS_Store
build
dist
docs/_build
.ipynb_checkpoints
# ignore config file at the top-level of the repo
# but not sub-dirs
/jupyterhub_config.py

View File

@@ -2,6 +2,7 @@
language: python
sudo: false
python:
- 3.5
- 3.4
- 3.3
before_install:
@@ -10,8 +11,11 @@ before_install:
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
install:
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
- pip install -f travis-wheels/wheelhouse ipython[notebook]
script:
- py.test --cov jupyterhub jupyterhub/tests -v
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
after_success:
- coveralls
- codecov
matrix:
include:
- python: 3.5
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000

View File

@@ -1,35 +1,63 @@
# A base docker image that includes juptyerhub and IPython master
# An incomplete base Docker image for running JupyterHub
#
# Build your own derivative images starting with
# Add your configuration to create a complete derivative Docker image.
#
# FROM jupyter/jupyterhub:latest
# Include your configuration settings by starting with one of two options:
#
# Option 1:
#
# FROM jupyterhub/jupyterhub:latest
#
# And put your configuration file jupyterhub_config.py in /srv/jupyterhub/jupyterhub_config.py.
#
# Option 2:
#
# Or you can create your jupyterhub config and database on the host machine, and mount it with:
#
# docker run -v $PWD:/srv/jupyterhub -t jupyterhub/jupyterhub
#
# NOTE
# If you base on jupyterhub/jupyterhub-onbuild
# your jupyterhub_config.py will be added automatically
# from your docker directory.
FROM jupyter/notebook
FROM debian:jessie
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
# install nodejs, utf8 locale
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get -y update && \
apt-get -y upgrade && \
apt-get -y install npm nodejs nodejs-legacy wget locales git &&\
/usr/sbin/update-locale LANG=C.UTF-8 && \
locale-gen C.UTF-8 && \
apt-get remove -y locales && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV LANG C.UTF-8
# install Python with conda
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.0.5-Linux-x86_64.sh -O /tmp/miniconda.sh && \
echo 'a7bcd0425d8b6688753946b59681572f63c2241aed77bf0ec6de4c5edc5ceeac */tmp/miniconda.sh' | shasum -a 256 -c - && \
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
/opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \
/opt/conda/bin/pip install --upgrade pip && \
rm /tmp/miniconda.sh
ENV PATH=/opt/conda/bin:$PATH
# install js dependencies
RUN npm install -g configurable-http-proxy
RUN npm install -g configurable-http-proxy && rm -rf ~/.npm
RUN mkdir -p /srv/
ADD . /src/jupyterhub
WORKDIR /src/jupyterhub
# install jupyterhub
ADD requirements.txt /tmp/requirements.txt
RUN pip3 install -r /tmp/requirements.txt
RUN python setup.py js && pip install . && \
rm -rf $PWD ~/.cache ~/.npm
WORKDIR /srv/
ADD . /srv/jupyterhub
RUN mkdir -p /srv/jupyterhub/
WORKDIR /srv/jupyterhub/
RUN pip3 install .
WORKDIR /srv/jupyterhub/
# Derivative containers should add jupyterhub config,
# which will be used when starting the application.
EXPOSE 8000
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
LABEL org.jupyter.service="jupyterhub"
CMD ["jupyterhub"]

View File

@@ -4,7 +4,9 @@ include setupegg.py
include bower.json
include package.json
include *requirements.txt
include Dockerfile
graft onbuild
graft jupyterhub
graft scripts
graft share

117
README.md
View File

@@ -3,8 +3,13 @@
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/jupyterhub/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyterhub/jupyterhub)
[![Circle CI](https://circleci.com/gh/jupyterhub/jupyterhub.svg?style=shield&circle-token=b5b65862eb2617b9a8d39e79340b0a6b816da8cc)](https://circleci.com/gh/jupyterhub/jupyterhub)
[![Documentation Status](https://readthedocs.org/projects/jupyterhub/badge/?version=latest)](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
[![codecov.io](https://codecov.io/github/jupyterhub/jupyterhub/coverage.svg?branch=master)](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
Three actors:
@@ -22,63 +27,57 @@ Basic principles:
## Dependencies
JupyterHub requires IPython >= 3.0 (current master) and Python >= 3.3.
JupyterHub itself requires [Python](https://www.python.org/downloads/) ≥ 3.3. To run the single-user servers (which may be on the same system as the Hub or not), [Jupyter Notebook](https://jupyter.readthedocs.org/en/latest/install.html) ≥ 4 is required.
You will need nodejs/npm, which you can get from your package manager:
Install [nodejs/npm](https://www.npmjs.com/), which is available from your
package manager. For example, install on Linux (Debian/Ubuntu) using:
sudo apt-get install npm nodejs-legacy
(The `nodejs-legacy` package installs the `node` executable,
which is required for npm to work on Debian/Ubuntu at this point)
(The `nodejs-legacy` package installs the `node` executable and is currently
required for npm to work on Debian/Ubuntu.)
Then install javascript dependencies:
Next, install JavaScript dependencies:
sudo npm install -g configurable-http-proxy
### Optional
### (Optional) Installation Prerequisite (pip)
- Notes on `pip` command used in the below installation sections:
- `sudo` may be needed for `pip install`, depending on filesystem permissions.
- JupyterHub requires Python >= 3.3, so it may be required on some machines to use `pip3` instead
of `pip` (especially when you have both Python 2 and Python 3 installed on your machine).
If `pip3` is not found on your machine, you can get it by doing:
Notes on the `pip` command used in the installation directions below:
- `sudo` may be needed for `pip install`, depending on the user's filesystem permissions.
- JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu):
sudo apt-get install python3-pip
## Installation
As usual start with cloning the code:
JupyterHub can be installed with pip, and the proxy with npm:
git clone https://github.com/jupyter/jupyterhub.git
cd jupyterhub
npm install -g configurable-http-proxy
pip3 install jupyterhub
Then you can install the Python package by doing:
If you plan to run notebook servers locally, you may also need to install the
Jupyter ~~IPython~~ notebook:
pip3 install -r requirements.txt
pip3 install .
If the `pip3 install .` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional javascript dependencies:
npm install
If you plan to run notebook servers locally, you may also need to install the IPython notebook:
pip3 install "ipython[notebook]"
This will fetch client-side javascript dependencies and compile CSS,
and install these files to `sys.prefix`/share/jupyter, as well as
install any Python dependencies.
pip3 install --upgrade notebook
### Development install
For a development install:
For a development install, clone the repository and then install from source:
git clone https://github.com/jupyterhub/jupyterhub
cd jupyterhub
pip3 install -r dev-requirements.txt -e .
In which case you may need to manually update javascript and css after some updates, with:
If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies:
npm install
This will fetch client-side JavaScript dependencies necessary to compile CSS.
You may also need to manually update JavaScript and CSS after some development updates, with:
python3 setup.py js # fetch updated client-side js (changes rarely)
python3 setup.py css # recompile CSS from LESS sources
@@ -92,22 +91,24 @@ To start the server, run the command:
and then visit `http://localhost:8000`, and sign in with your unix credentials.
If you want multiple users to be able to sign into the server, you will need to run the
`jupyterhub` command as a privileged user, such as root.
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server
as a less privileged user, which requires more configuration of the system.
To allow multiple users to sign into the server, you will need to
run the `jupyterhub` command as a *privileged user*, such as root.
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
describes how to run the server as a *less privileged user*, which requires more
configuration of the system.
## Getting started
see the [getting started doc](docs/getting-started.md) for some of the basics of configuring your JupyterHub deployment.
See the [getting started document](docs/source/getting-started.md) for the
basics of configuring your JupyterHub deployment.
### Some examples
generate a default config file:
Generate a default config file:
jupyterhub --generate-config
spawn the server on 10.0.1.2:443 with https:
Spawn the server on ``10.0.1.2:443`` with **https**:
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
@@ -115,8 +116,29 @@ The authentication and process spawning mechanisms can be replaced,
which should allow plugging into a variety of authentication or process control environments.
Some examples, meant as illustration and testing of this concept:
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
### Docker
There is a ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/).
[Note: This `jupyterhub/jupyterhub` docker image is only an image for running the Hub service itself.
It does not require the other Jupyter components, which are needed by the single-user servers.
To run the single-user servers, which may be on the same system as the Hub or not, installation of Jupyter Notebook ≥ 4 is required.]
The JupyterHub docker image can be started with the following command:
docker run -d --name jupyterhub jupyter/jupyterhub jupyterhub
This command will create a container named `jupyterhub` that you can stop and resume with `docker stop/start`.
It will be listening on all interfaces at port 8000, so this is perfect to test JupyterHub on your desktop or laptop.
If you want to run docker on a computer that has a public IP then you should (as in MUST) secure it with ssl by
adding ssl options to your docker configuration or using a ssl enabled proxy.
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/) will
allow you to store data outside the docker image (host system) so it will be persistent, even when you start
a new image. The command `docker exec -it jupyterhub bash` will spawn a root shell in your docker
container. You can use it to create system users in the container. These accounts will be used for authentication
in jupyterhub's default configuration. In order to run without SSL (for testing purposes only), you'll need to set `--no-ssl` explicitly.
# Getting help
@@ -124,6 +146,13 @@ We encourage you to ask questions on the mailing list:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter)
but you can participate in development discussions or get live help on Gitter:
and you may participate in development discussions or get live help on Gitter:
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge)
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyterhub/jupyterhub?utm_source=badge&utm_medium=badge)
## Resources
- [Project Jupyter website](https://jupyter.org)
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.org/en/latest/) [[PDF](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf)]
- [Documentation for Project Jupyter](http://jupyter.readthedocs.org/en/latest/index.html) [[PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)]
- [Issues](https://github.com/jupyter/jupyterhub/issues)
- [Technical support - Jupyter Google Group](https://groups.google.com/forum/#!forum/jupyter)

24
circle.yml Normal file
View File

@@ -0,0 +1,24 @@
machine:
services:
- docker
dependencies:
override:
- ls
test:
override:
- docker build -t jupyterhub/jupyterhub .
- docker build -t jupyterhub/jupyterhub-onbuild:${CIRCLE_TAG:-latest} onbuild
deployment:
hub:
branch: master
commands:
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
- docker push jupyterhub/jupyterhub-onbuild
release:
tag: /.*/
commands:
- docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com
- docker push jupyterhub/jupyterhub-onbuild:$CIRCLE_TAG

View File

@@ -1,4 +1,5 @@
-r requirements.txt
coveralls
codecov
pytest-cov
pytest
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."

View File

@@ -1,389 +0,0 @@
# Getting started with JupyterHub
This document describes some of the basics of configuring JupyterHub to do what you want.
JupyterHub is highly customizable, so there's a lot to cover.
## Installation
See [the readme](../README.md) for help installing JupyterHub.
## Overview
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
There are three main categories of processes run by the `jupyterhub` command line program:
- *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system
when they log in. The object that starts these processes is called a *Spawner*.
- *Proxy*: the public facing part of the server that uses a dynamic proxy to route HTTP requests
to the *Hub* and *Single User Servers*.
- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* using a *Spawner*.
## JupyterHub's default behavior
To start JupyterHub in its default configuration, type the following at the command line:
sudo jupyterhub
The default Authenticator that ships with JupyterHub authenticates users
with their system name and password (via [PAM][]).
Any user on the system with a password will be allowed to start a single-user notebook server.
The default Spawner starts servers locally as each user, one dedicated server per user.
These servers listen on localhost, and start in the given user's home directory.
By default, the *Proxy* listens on all public interfaces on port 8000.
Thus you can reach JupyterHub through:
http://localhost:8000
or any other public IP or domain pointing to your system.
In their default configuration, the other services, the *Hub* and *Single-User Servers*,
all communicate with each other on localhost only.
**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
You should not run JupyterHub without SSL encryption on a public network.
See [below](#Security) for how to configure JupyterHub to use SSL.
By default, starting JupyterHub will write two files to disk in the current working directory:
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the *Hub*.
This file allows the *Hub* to remember what users are running and where,
as well as other information enabling you to restart parts of JupyterHub separately.
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
The cookie secret file is discussed [below](#Security).
The location of these files can be specified via configuration, discussed below.
## How to configure JupyterHub
JupyterHub is configured in two ways:
1. Command-line arguments
2. Configuration files
Type the following for brief information about the command line arguments:
jupyterhub -h
or:
jupyterhub --help-all
for the full command line help.
By default, JupyterHub will look for a configuration file (can be missing)
named `jupyterhub_config.py` in the current working directory.
You can create an empty configuration file with
jupyterhub --generate-config
This empty configuration file has descriptions of all configuration variables and their default values.
You can load a specific config file with:
jupyterhub -f /path/to/jupyterhub_config.py
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
on the config system Jupyter uses.
## Networking
In most situations you will want to change the main IP address and port of the Proxy.
This address determines where JupyterHub is available to your users.
The default is all network interfaces (`''`) on port 8000.
This can be done with the following command line arguments:
jupyterhub --ip=192.168.1.2 --port=443
Or you can put the following lines in a configuration file:
```python
c.JupyterHub.ip = '192.168.1.2'
c.JupyterHub.port = 443
```
Port 443 is used in these examples as it is the default port for SSL/HTTPS.
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
However, for more customized scenarios,
you can configure the following additional networking details.
The Hub service talks to the proxy via a REST API on a secondary port,
whose network interface and port can be configured separately.
By default, this REST API listens on port 8081 of localhost only.
If you want to run the Proxy separate from the Hub,
you may need to configure this IP and port with:
```python
# ideally a private network address
c.JupyterHub.proxy_api_ip = '10.0.1.4'
c.JupyterHub.proxy_api_port = 5432
```
The Hub service also listens only on localhost (port 8080) by default.
The Hub needs needs to be accessible from both the proxy and all Spawners.
When spawning local servers localhost is fine,
but if *either* the Proxy or (more likely) the Spawners will be remote or isolated in containers,
the Hub must listen on an IP that is accessible.
```python
c.JupyterHub.hub_ip = '10.0.1.4'
c.JupyterHub.hub_port = 54321
```
## Security
First of all, since JupyterHub includes authentication and allows arbitrary code execution,
you should not run it without SSL (HTTPS).
This will require you to obtain an official SSL certificate or create a self-signed certificate.
Once you have obtained and installed a key and certificate
you need to pass their locations to JupyterHub's configuration as follows:
```python
c.JupyterHub.ssl_key = '/path/to/my.key'
c.JupyterHub.ssl_cert = '/path/to/my.cert'
```
Some cert files also contain the key, in which case only the cert is needed.
It is important that these files be put in a secure location on your server.
There are two other aspects of JupyterHub network security.
The cookie secret is an encryption key, used to encrypt the cookies used for authentication.
If this value changes for the Hub,
all single-user servers must also be restarted.
Normally, this value is stored in the file `jupyterhub_cookie_secret`,
which can be specified with:
```python
c.JupyterHub.cookie_secret_file = '/path/to/jupyterhub_cookie_secret'
```
In most deployments of JupyterHub, you should point this to a secure location on the file system.
If the cookie secret file doesn't exist when the Hub starts,
a new cookie secret is generated and stored in the file.
If you would like to avoid the need for files,
the value can be loaded in the Hub process from the `JPY_COOKIE_SECRET` env variable:
```bash
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
```
For security reasons, this env variable should only be visible to the Hub.
The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`.
If you want to be able to start or restart the proxy or Hub independently of each other (not always necessary),
you must set this environment variable before starting the server (for both the Hub and Proxy):
```bash
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
```
This env variable needs to be visible to the Hub and Proxy.
If you don't set this, the Hub will generate a random key itself,
which means that any time you restart the Hub you **must also restart the Proxy**.
If the proxy is a subprocess of the Hub,
this should happen automatically (this is the default configuration).
## Configuring Authentication
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
You can restrict which users are allowed to login with `Authenticator.whitelist`:
```python
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
```
Admin users of JupyterHub have the ability to take actions on users' behalf,
such as stopping and restarting their servers,
and adding and removing new users from the whitelist.
Any users in the admin list are automatically added to the whitelist,
if they are not already present.
The set of initial Admin users can configured as follows:
```python
c.Authenticator.admin_users = {'mal', 'zoe'}
```
If `JupyterHub.admin_access` is True (not default),
then admin users have permission to log in *as other users* on their respective machines, for debugging.
**You should make sure your users know if admin_access is enabled.**
### Adding and removing users
Users can be added and removed to the Hub via the admin panel or REST API.
These users will be added to the whitelist and database.
Restarting the Hub will not require manually updating the whitelist in your config file,
as the users will be loaded from the database.
This means that after starting the Hub once,
it is not sufficient to remove users from the whitelist in your config file.
You must also remove them from the database, either by discarding the database file,
or via the admin UI.
The default PAMAuthenticator is one case of a special kind of authenticator,
called a LocalAuthenticator,
indicating that it manages users on the local system.
When you add a user to the Hub, a LocalAuthenticator checks if that user already exists.
Normally, there will be an error telling you that the user doesn't exist.
If you set the configuration value
```python
c.LocalAuthenticator.create_system_users = True
```
however, adding a user to the Hub that doesn't already exist on the system
will result in the Hub creating that user via the system `useradd` mechanism.
This option is typically used on hosted deployments of JupyterHub,
to avoid the need to manually create all your users before launching the service.
It is not recommended when running JupyterHub in situations where JupyterHub users maps directly onto UNIX users.
## Configuring single-user servers
Since the single-user server is an instance of `ipython notebook`,
an entire separate multi-process application,
there are many aspect of that server can configure,
and a lot of ways to express that configuration.
At the JupyterHub level, you can set some values on the Spawner.
The simplest of these is `Spawner.notebook_dir`,
which lets you set the root directory for a user's server.
This root notebook directory is the highest level directory users will be able to access in the notebook dashboard.
In this example, the root notebook directory is set to `~/notebooks`,
where `~` is expanded to the user's home directory.
```python
c.Spawner.notebook_dir = '~/notebooks'
```
You can also specify extra command-line arguments to the notebook server with:
```python
c.Spawner.args = ['--debug', '--profile=PHYS131']
```
This could be used to set the users default page for the single user server:
```python
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Since the single-user server extends the notebook server application,
it still loads configuration from the `ipython_notebook_config.py` config file.
Each user may have one of these files in `$HOME/.ipython/profile_default/`.
IPython also supports loading system-wide config files from `/etc/ipython/`,
which is the place to put configuration that you want to affect all of your users.
## External services
JupyterHub has a REST API that can be used to run external services.
More detail on this API will be added in the future.
## File locations
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
* `/srv/jupyterhub` for all security and runtime files
* `/etc/jupyterhub` for all configuration files
* `/var/log` for log files
## Example
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
* JupyterHub is running on a single cloud server
* Using SSL on the standard HTTPS port 443
* You want to use [GitHub OAuth][oauthenticator] for login
* You need the users to exist locally on the server
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
other users home directories
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
Let's start out with `jupyterhub_config.py`:
```python
# jupyterhub_config.py
c = get_config()
import os
pjoin = os.path.join
runtime_dir = os.path.join('/srv/jupyterhub')
ssl_dir = pjoin(runtime_dir, 'ssl')
if not os.path.exists(ssl_dir):
os.makedirs(ssl_dir)
# https on :443
c.JupyterHub.port = 443
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
# put the JupyterHub cookie secret and state db
# in /var/run/jupyterhub
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
# put the log file in /var/log
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
# use GitHub OAuthenticator for local users
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
# create system users that don't exist yet
c.LocalAuthenticator.create_system_users = True
# specify users and admin
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/ipython/ipython_notebook_config.py
c.Spawner.notebook_dir = '~/assignments'
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
which we will need to set when we launch the server:
```bash
export GITHUB_CLIENT_ID=github_id
export GITHUB_CLIENT_SECRET=github_secret
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
export CONFIGPROXY_AUTH_TOKEN=super-secret
jupyterhub -f /path/to/aboveconfig.py
```
# Further reading
- TODO: troubleshooting
- [Custom Authenticators](authenticators.md)
- [Custom Spawners](spawners.md)
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
[oauthenticator]: https://github.com/jupyter/oauthenticator
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module

263
docs/make.bat Normal file
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>=1.3.6
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

@@ -63,6 +63,37 @@ For local user authentication (e.g. PAM), this lets you limit which users
can login.
## Normalizing and validating usernames
Since the Authenticator and Spawner both use the same username,
sometimes you want to transform the name coming from the authentication service
(e.g. turning email addresses into local system usernames) before adding them to the Hub service.
Authenticators can define `normalize_username`, which takes a username.
The default normalization is to cast names to lowercase
For simple mappings, a configurable dict `Authenticator.username_map` is used to turn one name into another:
```python
c.Authenticator.username_map = {
'service-name': 'localname'
}
```
### Validating usernames
In most cases, there is a very limited set of acceptable usernames.
Authenticators can define `validate_username(username)`,
which should return True for a valid username and False for an invalid one.
The primary effect this has is improving error messages during user creation.
The default behavior is to use configurable `Authenticator.username_pattern`,
which is a regular expression string for validation.
To only allow usernames that start with 'w':
c.Authenticator.username_pattern = r'w.*'
## OAuth and other non-password logins
Some login mechanisms, such as [OAuth][], don't map onto username+password.
@@ -72,9 +103,9 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut
at [OAuthenticator][].
[Authenticator]: ../jupyterhub/auth.py
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module
[OAuth]: http://en.wikipedia.org/wiki/OAuth
[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
[OAuth]: https://en.wikipedia.org/wiki/OAuth
[GitHub OAuth]: https://developer.github.com/v3/oauth/
[OAuthenticator]: https://github.com/jupyter/oauthenticator

76
docs/source/changelog.md Normal file
View File

@@ -0,0 +1,76 @@
# Summary of changes in JupyterHub
See `git log` for a more detailed summary.
## 0.6
### 0.6.1
Bugfixes on 0.6:
- statsd is an optional dependency, only needed if in use
- Notice more quickly when servers have crashed
- Better error pages for proxy errors
- Add Stop All button to admin panel for stopping all servers at once
### 0.6.0
- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc.
- `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this
- Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}`
- Update to traitlets 4.1 `@default`, `@observe` APIs for traits
- Allow disabling PAM sessions via `c.PAMAuthenticator.open_sessions = False`. This may be needed on SELinux-enabled systems, where our PAM session logic often does not work properly
- Add `Spawner.environment` configurable, for defining extra environment variables to load for single-user servers
- JupyterHub API tokens can be pregenerated and loaded via `JupyterHub.api_tokens`, a dict of `token: username`.
- JupyterHub API tokens can be requested via the REST API, with a POST request to `/api/authorizations/token`.
This can only be used if the Authenticator has a username and password.
- Various fixes for user URLs and redirects
## 0.5
- Single-user server must be run with Jupyter Notebook ≥ 4.0
- Require `--no-ssl` confirmation to allow the Hub to be run without SSL (e.g. behind SSL termination in nginx)
- Add lengths to text fields for MySQL support
- Add `Spawner.disable_user_config` for preventing user-owned configuration from modifying single-user servers.
- Fixes for MySQL support.
- Add ability to run each user's server on its own subdomain. Requires wildcard DNS and wildcard SSL to be feasible. Enable subdomains by setting `JupyterHub.subdomain_host = 'https://jupyterhub.domain.tld[:port]'`.
- Use `127.0.0.1` for local communication instead of `localhost`, avoiding issues with DNS on some systems.
- Fix race that could add users to proxy prematurely if spawning is slow.
## 0.4
### 0.4.1
Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
### 0.4.0
- Add `Spawner.user_options_form` for specifying an HTML form to present to users,
allowing users to influence the spawning of their own servers.
- Add `Authenticator.pre_spawn_start` and `Authenticator.post_spawn_stop` hooks,
so that Authenticators can do setup or teardown (e.g. passing credentials to Spawner,
mounting data sources, etc.).
These methods are typically used with custom Authenticator+Spawner pairs.
- 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0.
## 0.3
- No longer make the user starting the Hub an admin
- start PAM sessions on login
- hooks for Authenticators to fire before spawners start and after they stop,
allowing deeper interaction between Spawner/Authenticator pairs.
- login redirect fixes
## 0.2
- Based on standalone traitlets instead of IPython.utils.traitlets
- multiple users in admin panel
- Fixes for usernames that require escaping
## 0.1
First preview release

386
docs/source/conf.py Normal file
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,439 @@
# Getting started with JupyterHub
This document describes some of the basics of configuring JupyterHub to do what you want.
JupyterHub is highly customizable, so there's a lot to cover.
## Installation
See [the readme](https://github.com/jupyter/jupyterhub/blob/master/README.md) for help installing JupyterHub.
## Overview
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
There are three main categories of processes run by the `jupyterhub` command line program:
- **Single User Server**: a dedicated, single-user, Jupyter Notebook is started for each user on the system
when they log in. The object that starts these processes is called a Spawner.
- **Proxy**: the public facing part of the server that uses a dynamic proxy to route HTTP requests
to the Hub and Single User Servers.
- **Hub**: manages user accounts and authentication and coordinates Single Users Servers using a Spawner.
## JupyterHub's default behavior
**IMPORTANT:** In its default configuration, JupyterHub requires SSL encryption (HTTPS) to run.
**You should not run JupyterHub without SSL encryption on a public network.**
See [Security documentation](#Security) for how to configure JupyterHub to use SSL, and in
certain cases, e.g. behind SSL termination in nginx, allowing the hub to run with no SSL
by requiring `--no-ssl` (as of [version 0.5](./changelog.html)).
To start JupyterHub in its default configuration, type the following at the command line:
sudo jupyterhub
The default Authenticator that ships with JupyterHub authenticates users
with their system name and password (via [PAM][]).
Any user on the system with a password will be allowed to start a single-user notebook server.
The default Spawner starts servers locally as each user, one dedicated server per user.
These servers listen on localhost, and start in the given user's home directory.
By default, the **Proxy** listens on all public interfaces on port 8000.
Thus you can reach JupyterHub through either:
http://localhost:8000
or any other public IP or domain pointing to your system.
In their default configuration, the other services, the **Hub** and **Single-User Servers**,
all communicate with each other on localhost only.
By default, starting JupyterHub will write two files to disk in the current working directory:
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the **Hub**.
This file allows the **Hub** to remember what users are running and where,
as well as other information enabling you to restart parts of JupyterHub separately.
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
The cookie secret file is discussed in the [Cookie Secret documentation](#Cookie secret).
The location of these files can be specified via configuration, discussed below.
## How to configure JupyterHub
JupyterHub is configured in two ways:
1. Configuration file
2. Command-line arguments
### Configuration file
By default, JupyterHub will look for a configuration file (which may not be created yet)
named `jupyterhub_config.py` in the current working directory.
You can create an empty configuration file with:
jupyterhub --generate-config
This empty configuration file has descriptions of all configuration variables and their default
values. You can load a specific config file with:
jupyterhub -f /path/to/jupyterhub_config.py
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
on the config system Jupyter uses.
### Command-line arguments
Type the following for brief information about the command-line arguments:
jupyterhub -h
or:
jupyterhub --help-all
for the full command line help.
All configurable options are technically configurable on the command-line,
even if some are really inconvenient to type. Just replace the desired option,
c.Class.trait, with --Class.trait. For example, to configure
c.Spawner.notebook_dir = '~/assignments' from the command-line:
jupyterhub --Spawner.notebook_dir='~/assignments'
## Networking
### Configuring the Proxy's IP address and port
The Proxy's main IP address setting determines where JupyterHub is available to users.
By default, JupyterHub is configured to be available on all network interfaces
(`''`) on port 8000. **Note**: Use of `'*'` is discouraged for IP configuration;
instead, use of `'0.0.0.0'` is preferred.
Changing the IP address and port can be done with the following command line
arguments:
jupyterhub --ip=192.168.1.2 --port=443
Or by placing the following lines in a configuration file:
```python
c.JupyterHub.ip = '192.168.1.2'
c.JupyterHub.port = 443
```
Port 443 is used as an example since 443 is the default port for SSL/HTTPS.
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
However, more customized scenarios may need additional networking details to
be configured.
### Configuring the Proxy's REST API communication IP address and port (optional)
The Hub service talks to the proxy via a REST API on a secondary port,
whose network interface and port can be configured separately.
By default, this REST API listens on port 8081 of localhost only.
If running the Proxy separate from the Hub,
configure the REST API communication IP address and port with:
```python
# ideally a private network address
c.JupyterHub.proxy_api_ip = '10.0.1.4'
c.JupyterHub.proxy_api_port = 5432
```
### Configuring the Hub if Spawners or Proxy are remote or isolated in containers
The Hub service also listens only on localhost (port 8080) by default.
The Hub needs needs to be accessible from both the proxy and all Spawners.
When spawning local servers, an IP address setting of localhost is fine.
If *either* the Proxy *or* (more likely) the Spawners will be remote or
isolated in containers, the Hub must listen on an IP that is accessible.
```python
c.JupyterHub.hub_ip = '10.0.1.4'
c.JupyterHub.hub_port = 54321
```
## Security
**IMPORTANT:** In its default configuration, JupyterHub requires SSL encryption (HTTPS) to run.
**You should not run JupyterHub without SSL encryption on a public network.**
Security is the most important aspect of configuring Jupyter. There are three main aspects of the
security configuration:
1. SSL encryption (to enable HTTPS)
2. Cookie secret (a key for encrypting browser cookies)
3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy)
## SSL encryption
Since JupyterHub includes authentication and allows arbitrary code execution, you should not run
it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or
create a self-signed certificate. Once you have obtained and installed a key and certificate you
need to specify their locations in the configuration file as follows:
```python
c.JupyterHub.ssl_key = '/path/to/my.key'
c.JupyterHub.ssl_cert = '/path/to/my.cert'
```
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain a free, trusted SSL
certificate. If you run letsencrypt using the default options, the needed configuration is (replace `your.domain.com` by your fully qualified domain name):
```python
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/your.domain.com/privkey.pem'
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/your.domain.com/fullchain.pem'
```
Some cert files also contain the key, in which case only the cert is needed. It is important that
these files be put in a secure location on your server, where they are not readable by regular
users.
Note: In certain cases, e.g. behind SSL termination in nginx, allowing no SSL
running on the hub may be desired. To run the Hub without SSL, you must opt
in by configuring and confirming the `--no-ssl` option, added as of [version 0.5](./changelog.html).
## Cookie secret
The cookie secret is an encryption key, used to encrypt the browser cookies used for
authentication. If this value changes for the Hub, all single-user servers must also be restarted.
Normally, this value is stored in a file, the location of which can be specified in a config file
as follows:
```python
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
```
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
```bash
openssl rand -base64 2048 > /srv/jupyterhub/cookie_secret
```
In most deployments of JupyterHub, you should point this to a secure location on the file
system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when
the Hub starts, a new cookie secret is generated and stored in the file. The
file must not be readable by group or other or the server won't start.
The recommended permissions for the cookie secret file are 600 (owner-only rw).
If you would like to avoid the need for files, the value can be loaded in the Hub process from
the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You
can set it this way:
```bash
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
```
For security reasons, this environment variable should only be visible to the Hub.
If you set it dynamically as above, all users will be logged out each time the
Hub starts.
You can also set the secret in the configuration file itself as a binary string:
```python
c.JupyterHub.cookie_secret = bytes.fromhex('VERY LONG SECRET HEX STRING')
```
## Proxy authentication token
The Hub authenticates its requests to the Proxy using a secret token that the Hub and Proxy agree upon. The value of this string should be a random string (for example, generated by `openssl rand -hex 32`). You can pass this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN` environment variable:
```bash
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
```
This environment variable needs to be visible to the Hub and Proxy.
Or you can set the value in the configuration file:
```python
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
```
If you don't set the Proxy authentication token, the Hub will generate a random key itself, which
means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a
subprocess of the Hub, this should happen automatically (this is the default configuration).
Another time you must set the Proxy authentication token yourself is if you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader) to also be able to connect to the Proxy.
## Configuring authentication
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
You can restrict which users are allowed to login with `Authenticator.whitelist`:
```python
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
```
Admin users of JupyterHub have the ability to take actions on users' behalf,
such as stopping and restarting their servers,
and adding and removing new users from the whitelist.
Any users in the admin list are automatically added to the whitelist,
if they are not already present.
The set of initial Admin users can configured as follows:
```python
c.Authenticator.admin_users = {'mal', 'zoe'}
```
If `JupyterHub.admin_access` is True (not default),
then admin users have permission to log in *as other users* on their respective machines, for debugging.
**You should make sure your users know if admin_access is enabled.**
### Adding and removing users
Users can be added and removed to the Hub via the admin panel or REST API. These users will be
added to the whitelist and database. Restarting the Hub will not require manually updating the
whitelist in your config file, as the users will be loaded from the database. This means that
after starting the Hub once, it is not sufficient to remove users from the whitelist in your
config file. You must also remove them from the database, either by discarding the database file,
or via the admin UI.
The default `PAMAuthenticator` is one case of a special kind of authenticator, called a
`LocalAuthenticator`, indicating that it manages users on the local system. When you add a user to
the Hub, a `LocalAuthenticator` checks if that user already exists. Normally, there will be an
error telling you that the user doesn't exist. If you set the configuration value
```python
c.LocalAuthenticator.create_system_users = True
```
however, adding a user to the Hub that doesn't already exist on the system will result in the Hub
creating that user via the system `adduser` command line tool. This option is typically used on
hosted deployments of JupyterHub, to avoid the need to manually create all your users before
launching the service. It is not recommended when running JupyterHub in situations where
JupyterHub users maps directly onto UNIX users.
## Configuring single-user servers
Since the single-user server is an instance of `jupyter notebook`, an entire separate
multi-process application, there are many aspect of that server can configure, and a lot of ways
to express that configuration.
At the JupyterHub level, you can set some values on the Spawner. The simplest of these is
`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root
notebook directory is the highest level directory users will be able to access in the notebook
dashboard. In this example, the root notebook directory is set to `~/notebooks`, where `~` is
expanded to the user's home directory.
```python
c.Spawner.notebook_dir = '~/notebooks'
```
You can also specify extra command-line arguments to the notebook server with:
```python
c.Spawner.args = ['--debug', '--profile=PHYS131']
```
This could be used to set the users default page for the single user server:
```python
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Since the single-user server extends the notebook server application,
it still loads configuration from the `ipython_notebook_config.py` config file.
Each user may have one of these files in `$HOME/.ipython/profile_default/`.
IPython also supports loading system-wide config files from `/etc/ipython/`,
which is the place to put configuration that you want to affect all of your users.
## External services
JupyterHub has a REST API that can be used to run external services.
More detail on this API will be added in the future.
## File locations
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
* `/srv/jupyterhub` for all security and runtime files
* `/etc/jupyterhub` for all configuration files
* `/var/log` for log files
## Example
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
* JupyterHub is running on a single cloud server
* Using SSL on the standard HTTPS port 443
* You want to use [GitHub OAuth][oauthenticator] for login
* You need the users to exist locally on the server
* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within
other users home directories
* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory.
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
Let's start out with `jupyterhub_config.py`:
```python
# jupyterhub_config.py
c = get_config()
import os
pjoin = os.path.join
runtime_dir = os.path.join('/srv/jupyterhub')
ssl_dir = pjoin(runtime_dir, 'ssl')
if not os.path.exists(ssl_dir):
os.makedirs(ssl_dir)
# https on :443
c.JupyterHub.port = 443
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
# put the JupyterHub cookie secret and state db
# in /var/run/jupyterhub
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
# put the log file in /var/log
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
# use GitHub OAuthenticator for local users
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
# create system users that don't exist yet
c.LocalAuthenticator.create_system_users = True
# specify users and admin
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/ipython/ipython_notebook_config.py
c.Spawner.notebook_dir = '~/assignments'
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
which we will need to set when we launch the server:
```bash
export GITHUB_CLIENT_ID=github_id
export GITHUB_CLIENT_SECRET=github_secret
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
export CONFIGPROXY_AUTH_TOKEN=super-secret
jupyterhub -f /path/to/aboveconfig.py
```
# Further reading
- [Custom Authenticators](./authenticators.html)
- [Custom Spawners](./spawners.html)
- [Troubleshooting](./troubleshooting.html)
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
[oauthenticator]: https://github.com/jupyter/oauthenticator
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module

View File

@@ -5,7 +5,7 @@ JupyterHub is a multi-user server that manages and proxies multiple instances of
There are three basic processes involved:
- multi-user Hub (Python/Tornado)
- configurable http proxy (nodejs)
- [configurable http proxy](https://github.com/jupyter/configurable-http-proxy) (node-http-proxy)
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
The proxy is the only process that listens on a public interface.
@@ -51,7 +51,7 @@ Authentication is customizable via the Authenticator class.
Authentication can be replaced by any mechanism,
such as OAuth, Kerberos, etc.
JupyterHub only ships with [PAM](http://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication,
JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication,
which requires the server to be run as root,
or at least with access to the PAM service,
which regular users typically do not have

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

94
docs/source/index.rst Normal file
View File

@@ -0,0 +1,94 @@
JupyterHub
==========
JupyterHub is a server that gives multiple users access to Jupyter notebooks,
running an independent Jupyter notebook server for each user.
To use JupyterHub, you need a Unix server (typically Linux) running
somewhere that is accessible to your team on the network. The JupyterHub server
can be on an internal network at your organisation, or it can run on the public
internet (in which case, take care with `security <getting-started.html#security>`__).
Users access JupyterHub in a web browser, by going to the IP address or
domain name of the server.
Different :doc:`authenticators <authenticators>` control access
to JupyterHub. The default one (pam) uses the user accounts on the server where
JupyterHub is running. If you use this, you will need to create a user account
on the system for each user on your team. Using other authenticators, you can
allow users to sign in with e.g. a Github account, or with any single-sign-on
system your organisation has.
Next, :doc:`spawners <spawners>` control how JupyterHub starts
the individual notebook server for each user. The default spawner will
start a notebook server on the same machine running under their system username.
The other main option is to start each server in a separate container, often
using Docker.
JupyterHub runs as three separate parts:
* The multi-user Hub (Python & Tornado)
* A `configurable http proxy <https://github.com/jupyter/configurable-http-proxy>`_ (NodeJS)
* Multiple single-user Jupyter notebook servers (Python & Tornado)
Basic principles:
* Hub spawns proxy
* Proxy forwards ~all requests to hub by default
* Hub handles login, and spawns single-user servers on demand
* Hub configures proxy to forward url prefixes to single-user servers
Contents:
.. toctree::
:maxdepth: 2
:caption: User Documentation
getting-started
howitworks
websecurity
.. toctree::
:maxdepth: 2
:caption: Configuration
authenticators
spawners
troubleshooting
.. toctree::
:maxdepth: 1
:caption: Developer Documentation
api/index
.. toctree::
:maxdepth: 1
:caption: Community documentation
.. toctree::
:maxdepth: 2
:caption: About JupyterHub
changelog
.. toctree::
:maxdepth: 1
:caption: Questions? Suggestions?
Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>
Jupyter website <https://jupyter.org>
Stack Overflow - Jupyter <https://stackoverflow.com/questions/tagged/jupyter>
Stack Overflow - Jupyter-notebook <https://stackoverflow.com/questions/tagged/jupyter-notebook>
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

160
docs/source/spawners.md Normal file
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]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/spawner.py

View File

@@ -0,0 +1,99 @@
# Troubleshooting
This document is under active development.
When troubleshooting, you may see unexpected behaviors or receive an error
message. These two lists provide links to identifying the cause of the
problem and how to resolve it.
## Behavior problems
- [JupyterHub proxy fails to start](#jupyterhub-proxy-fails-to-start)
## Errors
- [500 error after spawning a single-user server](#500-error-after-spawning-my-single-user-server)
----
## JupyterHub proxy fails to start
If you have tried to start the JupyterHub proxy and it fails to start:
- check if the JupyterHub IP configuration setting is
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
- Try starting with ``jupyterhub --ip=0.0.0.0``
----
## 500 error after spawning my single-user server
You receive a 500 error when accessing the URL `/user/you/...`. This is often
seen when your single-user server cannot check your cookies with the Hub.
There are two likely reasons for this:
1. The single-user server cannot connect to the Hub's API (networking
configuration problems)
2. The single-user server cannot *authenticate* its requests (invalid token)
### Symptoms:
The main symptom is a failure to load *any* page served by the single-user
server, met with a 500 error. This is typically the first page at `/user/you`
after logging in or clicking "Start my server". When a single-user server
receives a request, it makes an API request to the Hub to check if the cookie
corresponds to the right user. This request is logged.
If everything is working, it will look like this:
```
200 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 6.10ms
```
You should see a similar 200 message, as above, in the Hub log when you first
visit your single-user server. If you don't see this message in the log, it
may mean that your single-user server isn't connecting to your Hub.
If you see 403 (forbidden) like this, it's a token problem:
```
403 GET /hub/api/authorizations/cookie/jupyter-hub-token-name/[secret] (@10.0.1.4) 4.14ms
```
Check the logs of the single-user server, which may have more detailed
information on the cause.
### Causes and resolutions:
#### No authorization request
If you make an API request and it is not received by the server, you likely
have a network configuration issue. Often, this happens when the Hub is only
listening on 127.0.0.1 (default) and the single-user servers are not on the
same 'machine' (can be physically remote, or in a docker container or VM). The
fix for this case is to make sure that `c.JupyterHub.hub_ip` is an address
that all single-user servers can connect to, e.g.:
```python
c.JupyterHub.hub_ip = '10.0.0.1'
```
#### 403 GET /hub/api/authorizations/cookie
If you receive a 403 error, the API token for the single-user server is likely
invalid. Commonly, the 403 error is caused by resetting the JupyterHub
database (either removing jupyterhub.sqlite or some other action) while
leaving single-user servers running. This happens most frequently when using
DockerSpawner, because Docker's default behavior is to stop/start containers
which resets the JupyterHub database, rather than destroying and recreating
the container every time. This means that the same API token is used by the
server for its whole life, until the container is rebuilt.
The fix for this Docker case is to remove any Docker containers seeing this
issue (typicaly all containers created before a certain point in time):
docker rm -f jupyter-name
After this, when you start your server via JupyterHub, it will build a
new container. If this was the underlying cause of the issue, you should see
your server again.

View File

@@ -0,0 +1,63 @@
# Web Security in JupyterHub
JupyterHub is designed to be a simple multi-user server for modestly sized groups of semi-trusted users.
While the design reflects serving semi-trusted users,
JupyterHub is not necessarily unsuitable for serving untrusted users.
Using JupyterHub with untrusted users does mean more work and much care is required to secure a Hub against untrusted users,
with extra caution on protecting users from each other as the Hub is serving untrusted users.
One aspect of JupyterHub's design simplicity for semi-trusted users is that the Hub and single-user servers are placed in a single domain, behind a [proxy][configurable-http-proxy].
As a result, if the Hub is serving untrusted users,
many of the web's cross-site protections are not applied between single-user servers and the Hub,
or between single-user servers and each other,
since browsers see the whole thing (proxy, Hub, and single user servers) as a single website.
To protect users from each other, a user must never be able to write arbitrary HTML and serve it to another user on the Hub's domain.
JupyterHub's authentication setup prevents this because only the owner of a given single-user server is allowed to view user-authored pages served by their server.
To protect all users from each other, JupyterHub administrators must ensure that:
* A user does not have permission to modify their single-user server:
- A user may not install new packages in the Python environment that runs their server.
- If the PATH is used to resolve the single-user executable (instead of an absolute path), a user may not create new files in any PATH directory that precedes the directory containing jupyterhub-singleuser.
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for their single-user server.
* A user may not modify the configuration of the notebook server (the ~/.jupyter or JUPYTER_CONFIG_DIR directory).
If any additional services are run on the same domain as the Hub, the services must never display user-authored HTML that is neither sanitized nor sandboxed (e.g. IFramed) to any user that lacks authentication as the author of a file.
## Mitigations
There are two main configuration options provided by JupyterHub to mitigate these issues:
### Subdomains
JupyterHub 0.5 adds the ability to run single-user servers on their own subdomains,
which means the cross-origin protections between servers has the desired effect,
and user servers and the Hub are protected from each other.
A user's server will be at `username.jupyter.mydomain.com`, etc.
This requires all user subdomains to point to the same address,
which is most easily accomplished with wildcard DNS.
Since this spreads the service across multiple domains, you will need wildcard SSL, as well.
Unfortunately, for many institutional domains, wildcard DNS and SSL are not available,
but if you do plan to serve untrusted users, enabling subdomains is highly encouraged,
as it resolves all of the cross-site issues.
### Disabling user config
If subdomains are not available or not desirable,
0.5 also adds an option `Spawner.disable_user_config`,
which you can set to prevent the user-owned configuration files from being loaded.
This leaves only package installation and PATHs as things the admin must enforce.
For most Spawners, PATH is not something users an influence,
but care should be taken to ensure that the Spawn does *not* evaluate shell configuration files prior to launching the server.
Package isolation is most easily handled by running the single-user server in a virtualenv with disabled system-site-packages.
## Extra notes
It is important to note that the control over the environment only affects the single-user server,
and not the environment(s) in which the user's kernel(s) may run.
Installing additional packages in the kernel environment does not pose additional risk to the web application's security.
[configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy

View File

@@ -1,89 +0,0 @@
# Writing a custom Spawner
Each single-user server is started by a [Spawner][].
The Spawner represents an abstract interface to a process,
and a custom Spawner needs to be able to take three actions:
1. start the process
2. poll whether the process is still running
3. stop the process
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
## Spawner.start
`Spawner.start` should start the single-user server for a single user.
Information about the user can be retrieved from `self.user`,
an object encapsulating the user's name, authentication, and server info.
When `Spawner.start` returns, it should have stored the IP and port
of the single-user server in `self.user.server`.
**NOTE:** when writing coroutines, *never* `yield` in between a db change and a commit.
Most `Spawner.start`s should have something looking like:
```python
def start(self):
self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub
self.user.server.port = 1234 # port selected somehow
self.db.commit() # always commit before yield, if modifying db values
yield self._actually_start_server_somehow()
```
When `Spawner.start` returns, the single-user server process should actually be running,
not just requested. JupyterHub can handle `Spawner.start` being very slow
(such as PBS-style batch queues, or instantiating whole AWS instances)
via relaxing the `Spawner.start_timeout` config value.
## Spawner.poll
`Spawner.poll` should check if the spawner is still running.
It should return `None` if it is still running,
and an integer exit status, otherwise.
For the local process case, this uses `os.kill(PID, 0)`
to check if the process is still around.
## Spawner.stop
`Spawner.stop` should stop the process. It must be a tornado coroutine,
and should return when the process has finished exiting.
## Spawner state
JupyterHub should be able to stop and restart without having to teardown
single-user servers. This means that a Spawner may need to persist
some information that it can be restored.
A dictionary of JSON-able state can be used to store this information.
Unlike start/stop/poll, the state methods must not be coroutines.
In the single-process case, this is only the process ID of the server:
```python
def get_state(self):
"""get the current state"""
state = super().get_state()
if self.pid:
state['pid'] = self.pid
return state
def load_state(self, state):
"""load state from the database"""
super().load_state(state)
if 'pid' in state:
self.pid = state['pid']
def clear_state(self):
"""clear any state (called after shutdown)"""
super().clear_state()
self.pid = 0
```
[Spawner]: ../jupyterhub/spawner.py

View File

@@ -1,4 +1,4 @@
FROM jupyter/jupyterhub
FROM jupyter/jupyterhub-onbuild
MAINTAINER Jupyter Project <jupyter@googlegroups.com>

View File

@@ -0,0 +1,46 @@
"""
Example JupyterHub config allowing users to specify environment variables and notebook-server args
"""
import shlex
from jupyterhub.spawner import LocalProcessSpawner
class DemoFormSpawner(LocalProcessSpawner):
def _options_form_default(self):
default_env = "YOURNAME=%s\n" % self.user.name
return """
<label for="args">Extra notebook CLI arguments</label>
<input name="args" placeholder="e.g. --debug"></input>
<label for="env">Environment variables (one per line)</label>
<textarea name="env">{env}</textarea>
""".format(env=default_env)
def options_from_form(self, formdata):
options = {}
options['env'] = env = {}
env_lines = formdata.get('env', [''])
for line in env_lines[0].splitlines():
if line:
key, value = line.split('=', 1)
env[key.strip()] = value.strip()
arg_s = formdata.get('args', [''])[0].strip()
if arg_s:
options['argv'] = shlex.split(arg_s)
return options
def get_args(self):
"""Return arguments to pass to the notebook server"""
argv = super().get_args()
if self.user_options.get('argv'):
argv.extend(self.user_options['argv'])
return argv
def get_env(self):
env = super().get_env()
if self.user_options.get('env'):
env.update(self.user_options['env'])
return env
c.JupyterHub.spawner_class = DemoFormSpawner

View File

@@ -6,7 +6,7 @@
import json
from urllib.parse import quote
from tornado import web
from tornado import web, gen
from .. import orm
from ..utils import token_authenticated
from .base import APIHandler
@@ -18,15 +18,27 @@ class TokenAPIHandler(APIHandler):
orm_token = orm.APIToken.find(self.db, token)
if orm_token is None:
raise web.HTTPError(404)
self.write(json.dumps(self.user_model(orm_token.user)))
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
@gen.coroutine
def post(self):
if self.authenticator is not None:
data = self.get_json_body()
username = yield self.authenticator.authenticate(self, data)
if username is None:
raise web.HTTPError(403)
user = self.find_user(username)
api_token = user.new_api_token()
self.write(json.dumps({"Authentication":api_token}))
else:
raise web.HTTPError(404)
class CookieAPIHandler(APIHandler):
@token_authenticated
def get(self, cookie_name, cookie_value=None):
cookie_name = quote(cookie_name, safe='')
if cookie_value is None:
self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
self.log.warning("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
cookie_value = self.request.body
else:
cookie_value = cookie_value.encode('utf8')
@@ -39,4 +51,5 @@ class CookieAPIHandler(APIHandler):
default_handlers = [
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
(r"/api/authorizations/token", TokenAPIHandler),
]

View File

@@ -26,25 +26,29 @@ class APIHandler(BaseHandler):
# If no header is provided, assume it comes from a script/curl.
# We are only concerned with cross-site browser stuff here.
if not host:
self.log.warn("Blocking API request with no host")
self.log.warning("Blocking API request with no host")
return False
if not referer:
self.log.warn("Blocking API request with no referer")
self.log.warning("Blocking API request with no referer")
return False
host_path = url_path_join(host, self.hub.server.base_url)
referer_path = referer.split('://', 1)[-1]
if not (referer_path + '/').startswith(host_path):
self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s",
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",
referer, host_path)
return False
return True
def get_current_user_cookie(self):
"""Override get_user_cookie to check Referer header"""
if not self.check_referer():
cookie_user = super().get_current_user_cookie()
# check referer only if there is a cookie user,
# avoiding misleading "Blocking Cross Origin" messages
# when there's no cookie set anyway.
if cookie_user and not self.check_referer():
return None
return super().get_current_user_cookie()
return cookie_user
def get_json_body(self):
"""Return the body of the request as JSON data."""
@@ -86,7 +90,7 @@ class APIHandler(BaseHandler):
model = {
'name': user.name,
'admin': user.admin,
'server': user.server.base_url if user.running else None,
'server': user.url if user.running else None,
'pending': None,
'last_activity': user.last_activity.isoformat(),
}

View File

@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
@gen.coroutine
def post(self):
"""POST checks the proxy to ensure"""
yield self.proxy.check_routes()
yield self.proxy.check_routes(self.users)
@admin_only
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
self.proxy.auth_token = model['auth_token']
self.db.commit()
self.log.info("Updated proxy at %s", server.bind_url)
yield self.proxy.check_routes()
yield self.proxy.check_routes(self.users)

View File

@@ -15,7 +15,7 @@ from .base import APIHandler
class UserListAPIHandler(APIHandler):
@admin_only
def get(self):
users = self.db.query(orm.User)
users = [ self._user_from_orm(u) for u in self.db.query(orm.User) ]
data = [ self.user_model(u) for u in users ]
self.write(json.dumps(data))
@@ -33,13 +33,25 @@ class UserListAPIHandler(APIHandler):
admin = data.get('admin', False)
to_create = []
invalid_names = []
for name in usernames:
name = self.authenticator.normalize_username(name)
if not self.authenticator.validate_username(name):
invalid_names.append(name)
continue
user = self.find_user(name)
if user is not None:
self.log.warn("User %s already exists" % name)
self.log.warning("User %s already exists" % name)
else:
to_create.append(name)
if invalid_names:
if len(invalid_names) == 1:
msg = "Invalid username: %s" % invalid_names[0]
else:
msg = "Invalid usernames: %s" % ', '.join(invalid_names)
raise web.HTTPError(400, msg)
if not to_create:
raise web.HTTPError(400, "All %i users already exist" % len(usernames))
@@ -51,11 +63,10 @@ class UserListAPIHandler(APIHandler):
self.db.commit()
try:
yield gen.maybe_future(self.authenticator.add_user(user))
except Exception:
except Exception as e:
self.log.error("Failed to create user: %s" % name, exc_info=True)
self.db.delete(user)
self.db.commit()
raise web.HTTPError(400, "Failed to create user: %s" % name)
del self.users[user]
raise web.HTTPError(400, "Failed to create user %s: %s" % (name, str(e)))
else:
created.append(user)
@@ -104,8 +115,8 @@ class UserAPIHandler(APIHandler):
yield gen.maybe_future(self.authenticator.add_user(user))
except Exception:
self.log.error("Failed to create user: %s" % name, exc_info=True)
self.db.delete(user)
self.db.commit()
# remove from registry
del self.users[user]
raise web.HTTPError(400, "Failed to create user: %s" % name)
self.write(json.dumps(self.user_model(user)))
@@ -127,10 +138,8 @@ class UserAPIHandler(APIHandler):
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
yield gen.maybe_future(self.authenticator.delete_user(user))
# remove from the db
self.db.delete(user)
self.db.commit()
# remove from registry
del self.users[user]
self.set_status(204)
@@ -152,12 +161,14 @@ class UserServerAPIHandler(APIHandler):
@admin_or_self
def post(self, name):
user = self.find_user(name)
if user.spawner:
state = yield user.spawner.poll()
if user.running:
# include notify, so that a server that died is noticed immediately
state = yield user.spawner.poll_and_notify()
if state is None:
raise web.HTTPError(400, "%s's server is already running" % name)
yield self.spawn_single_user(user)
options = self.get_json_body()
yield self.spawn_single_user(user, options=options)
status = 202 if user.spawn_pending else 201
self.set_status(status)
@@ -170,7 +181,8 @@ class UserServerAPIHandler(APIHandler):
return
if not user.running:
raise web.HTTPError(400, "%s's server is not running" % name)
status = yield user.spawner.poll()
# include notify, so that a server that died is noticed immediately
status = yield user.spawner.poll_and_notify()
if status is not None:
raise web.HTTPError(400, "%s's server is not running" % name)
yield self.stop_single_user(user)
@@ -185,7 +197,7 @@ class UserAdminAccessAPIHandler(APIHandler):
@admin_only
def post(self, name):
current = self.get_current_user()
self.log.warn("Admin user %s has requested access to %s's server",
self.log.warning("Admin user %s has requested access to %s's server",
current.name, name,
)
if not self.settings.get('admin_access', False):
@@ -196,6 +208,7 @@ class UserAdminAccessAPIHandler(APIHandler):
if not user.running:
raise web.HTTPError(400, "%s's server is not running" % name)
self.set_server_cookie(user)
current.other_user_cookies.add(name)
default_handlers = [

View File

@@ -16,6 +16,7 @@ from datetime import datetime
from distutils.version import LooseVersion as V
from getpass import getuser
from subprocess import Popen
from urllib.parse import urlparse
if sys.version_info[:2] < (3,3):
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
@@ -35,6 +36,7 @@ from tornado import gen, web
from traitlets import (
Unicode, Integer, Dict, TraitError, List, Bool, Any,
Type, Set, Instance, Bytes, Float,
observe, default,
)
from traitlets.config import Application, catch_config_error
@@ -42,9 +44,10 @@ here = os.path.dirname(__file__)
import jupyterhub
from . import handlers, apihandlers
from .handlers.static import CacheControlStaticFilesHandler
from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
from . import orm
from .user import User, UserDict
from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request
from .traitlets import URLPrefix, Command
@@ -56,6 +59,10 @@ from .utils import (
from .auth import Authenticator, PAMAuthenticator
from .spawner import Spawner, LocalProcessSpawner
# For faking stats
from .emptyclass import EmptyClass
common_aliases = {
'log-level': 'Application.log_level',
'f': 'JupyterHub.config_file',
@@ -86,6 +93,9 @@ flags = {
'no-db': ({'JupyterHub': {'db_url': 'sqlite:///:memory:'}},
"disable persisting state database to disk"
),
'no-ssl': ({'JupyterHub': {'confirm_no_ssl': True}},
"Allow JupyterHub to run without SSL (SSL termination should be happening elsewhere)."
),
}
SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets
@@ -170,87 +180,124 @@ class JupyterHub(Application):
PAMAuthenticator,
])
config_file = Unicode('jupyterhub_config.py', config=True,
config_file = Unicode('jupyterhub_config.py',
help="The config file to load",
)
generate_config = Bool(False, config=True,
).tag(config=True)
generate_config = Bool(False,
help="Generate default config file",
)
answer_yes = Bool(False, config=True,
).tag(config=True)
answer_yes = Bool(False,
help="Answer yes to any questions (e.g. confirm overwrite)"
)
pid_file = Unicode('', config=True,
).tag(config=True)
pid_file = Unicode('',
help="""File to write PID
Useful for daemonizing jupyterhub.
"""
)
cookie_max_age_days = Float(14, config=True,
).tag(config=True)
cookie_max_age_days = Float(14,
help="""Number of days for a login cookie to be valid.
Default is two weeks.
"""
)
last_activity_interval = Integer(300, config=True,
).tag(config=True)
last_activity_interval = Integer(300,
help="Interval (in seconds) at which to update last-activity timestamps."
)
proxy_check_interval = Integer(30, config=True,
).tag(config=True)
proxy_check_interval = Integer(30,
help="Interval (in seconds) at which to check if the proxy is running."
)
).tag(config=True)
data_files_path = Unicode(DATA_FILES_PATH, config=True,
data_files_path = Unicode(DATA_FILES_PATH,
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
)
).tag(config=True)
template_paths = List(
config=True,
help="Paths to search for jinja templates.",
)
).tag(config=True)
@default('template_paths')
def _template_paths_default(self):
return [os.path.join(self.data_files_path, 'templates')]
ssl_key = Unicode('', config=True,
confirm_no_ssl = Bool(False,
help="""Confirm that JupyterHub should be run without SSL.
This is **NOT RECOMMENDED** unless SSL termination is being handled by another layer.
"""
).tag(config=True)
ssl_key = Unicode('',
help="""Path to SSL key file for the public facing interface of the proxy
Use with ssl_cert
"""
)
ssl_cert = Unicode('', config=True,
).tag(config=True)
ssl_cert = Unicode('',
help="""Path to SSL certificate file for the public facing interface of the proxy
Use with ssl_key
"""
)
ip = Unicode('', config=True,
help="The public facing ip of the proxy"
)
port = Integer(8000, config=True,
).tag(config=True)
ip = Unicode('',
help="The public facing ip of the whole application (the proxy)"
).tag(config=True)
subdomain_host = Unicode('',
help="""Run single-user servers on subdomains of this host.
This should be the full https://hub.domain.tld[:port]
Provides additional cross-site protections for javascript served by single-user servers.
Requires <username>.hub.domain.tld to resolve to the same host as hub.domain.tld.
In general, this is most easily achieved with wildcard DNS.
When using SSL (i.e. always) this also requires a wildcard SSL certificate.
"""
).tag(config=True)
def _subdomain_host_changed(self, name, old, new):
if new and '://' not in new:
# host should include '://'
# if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new
port = Integer(8000,
help="The public facing port of the proxy"
)
base_url = URLPrefix('/', config=True,
).tag(config=True)
base_url = URLPrefix('/',
help="The base URL of the entire application"
)
).tag(config=True)
logo_file = Unicode('',
help="Specify path to a logo image to override the Jupyter logo in the banner."
).tag(config=True)
jinja_environment_options = Dict(config=True,
@default('logo_file')
def _logo_file_default(self):
return os.path.join(self.data_files_path, 'static', 'images', 'jupyter.png')
jinja_environment_options = Dict(
help="Supply extra arguments that will be passed to Jinja environment."
)
).tag(config=True)
proxy_cmd = Command('configurable-http-proxy', config=True,
proxy_cmd = Command('configurable-http-proxy',
help="""The command to start the http proxy.
Only override if configurable-http-proxy is not on your PATH
"""
)
debug_proxy = Bool(False, config=True, help="show debug output in configurable-http-proxy")
proxy_auth_token = Unicode(config=True,
).tag(config=True)
debug_proxy = Bool(False,
help="show debug output in configurable-http-proxy"
).tag(config=True)
proxy_auth_token = Unicode(
help="""The Proxy Auth token.
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
"""
)
).tag(config=True)
@default('proxy_auth_token')
def _proxy_auth_token_default(self):
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None)
if not token:
self.log.warn('\n'.join([
self.log.warning('\n'.join([
"",
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.",
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHub.proxy_auth_token config to avoid this message.",
@@ -259,47 +306,60 @@ class JupyterHub(Application):
token = orm.new_token()
return token
proxy_api_ip = Unicode('localhost', config=True,
proxy_api_ip = Unicode('127.0.0.1',
help="The ip for the proxy API handlers"
)
proxy_api_port = Integer(config=True,
).tag(config=True)
proxy_api_port = Integer(
help="The port for the proxy API handlers"
)
).tag(config=True)
@default('proxy_api_port')
def _proxy_api_port_default(self):
return self.port + 1
hub_port = Integer(8081, config=True,
hub_port = Integer(8081,
help="The port for this process"
)
hub_ip = Unicode('localhost', config=True,
).tag(config=True)
hub_ip = Unicode('127.0.0.1',
help="The ip for this process"
)
hub_prefix = URLPrefix('/hub/', config=True,
).tag(config=True)
hub_prefix = URLPrefix('/hub/',
help="The prefix for the hub server. Must not be '/'"
)
).tag(config=True)
@default('hub_prefix')
def _hub_prefix_default(self):
return url_path_join(self.base_url, '/hub/')
@observe('hub_prefix')
def _hub_prefix_changed(self, name, old, new):
if new == '/':
raise TraitError("'/' is not a valid hub prefix")
if not new.startswith(self.base_url):
self.hub_prefix = url_path_join(self.base_url, new)
cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET',
cookie_secret = Bytes(
help="""The cookie secret to use to encrypt cookies.
Loaded from the JPY_COOKIE_SECRET env variable by default.
"""
).tag(
config=True,
env='JPY_COOKIE_SECRET',
)
cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True,
cookie_secret_file = Unicode('jupyterhub_cookie_secret',
help="""File in which to store the cookie secret."""
)
).tag(config=True)
api_tokens = Dict(Unicode(),
help="""Dict of token:username to be loaded into the database.
Allows ahead-of-time generation of API tokens for use by services.
"""
).tag(config=True)
authenticator_class = Type(PAMAuthenticator, Authenticator,
config=True,
help="""Class for authenticating users.
This should be a class with the following form:
@@ -312,56 +372,69 @@ class JupyterHub(Application):
where `handler` is the calling web.RequestHandler,
and `data` is the POST form data from the login page.
"""
)
).tag(config=True)
authenticator = Instance(Authenticator)
@default('authenticator')
def _authenticator_default(self):
return self.authenticator_class(parent=self, db=self.db)
# class for spawning single-user servers
spawner_class = Type(LocalProcessSpawner, Spawner,
config=True,
help="""The class to use for spawning single-user servers.
Should be a subclass of Spawner.
"""
)
).tag(config=True)
db_url = Unicode('sqlite:///jupyterhub.sqlite', config=True,
db_url = Unicode('sqlite:///jupyterhub.sqlite',
help="url for the database. e.g. `sqlite:///jupyterhub.sqlite`"
)
def _db_url_changed(self, name, old, new):
).tag(config=True)
@observe('db_url')
def _db_url_changed(self, change):
new = change['new']
if '://' not in new:
# assume sqlite, if given as a plain filename
self.db_url = 'sqlite:///%s' % new
db_kwargs = Dict(config=True,
db_kwargs = Dict(
help="""Include any kwargs to pass to the database connection.
See sqlalchemy.create_engine for details.
"""
)
).tag(config=True)
reset_db = Bool(False, config=True,
reset_db = Bool(False,
help="Purge and reset the database."
)
debug_db = Bool(False, config=True,
).tag(config=True)
debug_db = Bool(False,
help="log all database transactions. This has A LOT of output"
)
).tag(config=True)
session_factory = Any()
admin_access = Bool(False, config=True,
users = Instance(UserDict)
@default('users')
def _users_default(self):
assert self.tornado_settings
return UserDict(db_factory=lambda : self.db, settings=self.tornado_settings)
admin_access = Bool(False,
help="""Grant admin users permission to access single-user servers.
Users should be properly informed if this is enabled.
"""
)
admin_users = Set(config=True,
).tag(config=True)
admin_users = Set(
help="""DEPRECATED, use Authenticator.admin_users instead."""
)
).tag(config=True)
tornado_settings = Dict(config=True)
tornado_settings = Dict(
help="Extra settings overrides to pass to the tornado application."
).tag(config=True)
cleanup_servers = Bool(True, config=True,
cleanup_servers = Bool(True,
help="""Whether to shutdown single-user servers when the Hub shuts down.
Disable if you want to be able to teardown the Hub while leaving the single-user servers running.
@@ -371,9 +444,9 @@ class JupyterHub(Application):
The Hub should be able to resume from database state.
"""
)
).tag(config=True)
cleanup_proxy = Bool(True, config=True,
cleanup_proxy = Bool(True,
help="""Whether to shutdown the proxy when the Hub shuts down.
Disable if you want to be able to teardown the Hub while leaving the proxy running.
@@ -385,7 +458,21 @@ class JupyterHub(Application):
The Hub should be able to resume from database state.
"""
)
).tag(config=True)
statsd_host = Unicode(
help="Host to send statds metrics to"
).tag(config=True)
statsd_port = Integer(
8125,
help="Port on which to send statsd metrics about the hub"
).tag(config=True)
statsd_prefix = Unicode(
'jupyterhub',
help="Prefix to use for all metrics sent by jupyterhub to statsd"
).tag(config=True)
handlers = List()
@@ -394,27 +481,46 @@ class JupyterHub(Application):
proxy_process = None
io_loop = None
@default('log_level')
def _log_level_default(self):
return logging.INFO
@default('log_datefmt')
def _log_datefmt_default(self):
"""Exclude date from default date format"""
return "%Y-%m-%d %H:%M:%S"
@default('log_format')
def _log_format_default(self):
"""override default log format to include time"""
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
extra_log_file = Unicode(
"",
config=True,
help="Set a logging.FileHandler on this file."
)
help="""Send JupyterHub's logs to this file.
This will *only* include the logs of the Hub itself,
not the logs of the proxy or any single-user servers.
"""
).tag(config=True)
extra_log_handlers = List(
Instance(logging.Handler),
config=True,
help="Extra log handlers to set on JupyterHub logger",
).tag(config=True)
statsd = Any(allow_none=False, help="The statsd client, if any. A mock will be used if we aren't using statsd")
@default('statsd')
def _statsd(self):
if self.statsd_host:
import statsd
client = statsd.StatsClient(
self.statsd_host,
self.statsd_port,
self.statsd_prefix
)
return client
else:
# return an empty mock object!
return EmptyClass()
def init_logging(self):
# This prevents double log messages because tornado use a root logger that
@@ -464,13 +570,14 @@ class JupyterHub(Application):
def init_handlers(self):
h = []
h.extend(handlers.default_handlers)
h.extend(apihandlers.default_handlers)
# load handlers from the authenticator
h.extend(self.authenticator.get_handlers(self))
# set default handlers
h.extend(handlers.default_handlers)
h.extend(apihandlers.default_handlers)
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
self.handlers = self.add_url_prefix(self.hub_prefix, h)
# some extra handlers, outside hub_prefix
self.handlers.extend([
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
@@ -498,30 +605,34 @@ class JupyterHub(Application):
def init_secrets(self):
trait_name = 'cookie_secret'
trait = self.traits()[trait_name]
env_name = trait.get_metadata('env')
env_name = trait.metadata.get('env')
secret_file = os.path.abspath(
os.path.expanduser(self.cookie_secret_file)
)
secret = self.cookie_secret
secret_from = 'config'
# load priority: 1. config, 2. env, 3. file
if not secret and os.environ.get(env_name):
secret_env = os.environ.get(env_name)
if not secret and secret_env:
secret_from = 'env'
self.log.info("Loading %s from env[%s]", trait_name, env_name)
secret = binascii.a2b_hex(os.environ[env_name])
secret = binascii.a2b_hex(secret_env)
if not secret and os.path.exists(secret_file):
secret_from = 'file'
perm = os.stat(secret_file).st_mode
if perm & 0o077:
self.log.error("Bad permissions on %s", secret_file)
else:
self.log.info("Loading %s from %s", trait_name, secret_file)
try:
perm = os.stat(secret_file).st_mode
if perm & 0o07:
raise ValueError("cookie_secret_file can be read or written by anybody")
with open(secret_file) as f:
b64_secret = f.read()
try:
secret = binascii.a2b_base64(b64_secret)
except Exception as e:
self.log.error("%s does not contain b64 key: %s", secret_file, e)
self.log.error(
"Refusing to run JupyterHub with invalid cookie_secret_file. "
"%s error was: %s",
secret_file, e)
self.exit(1)
if not secret:
secret_from = 'new'
self.log.debug("Generating new %s", trait_name)
@@ -536,7 +647,7 @@ class JupyterHub(Application):
try:
os.chmod(secret_file, 0o600)
except OSError:
self.log.warn("Failed to set permissions on %s", secret_file)
self.log.warning("Failed to set permissions on %s", secret_file)
# store the loaded trait value
self.cookie_secret = secret
@@ -554,11 +665,15 @@ class JupyterHub(Application):
q = self.db.query(orm.Hub)
assert q.count() <= 1
self._local.hub = q.first()
if self.subdomain_host and self._local.hub:
self._local.hub.host = self.subdomain_host
return self._local.hub
@hub.setter
def hub(self, hub):
self._local.hub = hub
if hub and self.subdomain_host:
hub.host = self.subdomain_host
@property
def proxy(self):
@@ -611,6 +726,10 @@ class JupyterHub(Application):
server.ip = self.hub_ip
server.port = self.hub_port
server.base_url = self.hub_prefix
if self.subdomain_host:
if not self.subdomain_host:
raise ValueError("Must specify subdomain_host when using subdomains."
" This should be the public domain[:port] of the Hub.")
self.db.commit()
@@ -620,18 +739,23 @@ class JupyterHub(Application):
db = self.db
if self.admin_users and not self.authenticator.admin_users:
self.log.warn(
self.log.warning(
"\nJupyterHub.admin_users is deprecated."
"\nUse Authenticator.admin_users instead."
)
self.authenticator.admin_users = self.admin_users
admin_users = self.authenticator.admin_users
admin_users = [
self.authenticator.normalize_username(name)
for name in self.authenticator.admin_users
]
self.authenticator.admin_users = set(admin_users) # force normalization
for username in admin_users:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
if not admin_users:
# add current user as admin if there aren't any others
admins = db.query(orm.User).filter(orm.User.admin==True)
if admins.first() is None:
admin_users.add(getuser())
self.log.warning("No admin users, admin interface will be unavailable.")
self.log.warning("Add any administrative users to `c.Authenticator.admin_users` in config.")
new_users = []
@@ -648,7 +772,14 @@ class JupyterHub(Application):
# the admin_users config variable will never be used after this point.
# only the database values will be referenced.
whitelist = self.authenticator.whitelist
whitelist = [
self.authenticator.normalize_username(name)
for name in self.authenticator.whitelist
]
self.authenticator.whitelist = set(whitelist) # force normalization
for username in whitelist:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
if not whitelist:
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
@@ -661,23 +792,51 @@ class JupyterHub(Application):
new_users.append(user)
db.add(user)
if whitelist:
# fill the whitelist with any users loaded from the db,
# so we are consistent in both directions.
db.commit()
# Notify authenticator of all users.
# This ensures Auth whitelist is up-to-date with the database.
# This lets whitelist be used to set up initial list,
# but changes to the whitelist can occur in the database,
# and persist across sessions.
for user in db.query(orm.User):
whitelist.add(user.name)
yield gen.maybe_future(self.authenticator.add_user(user))
db.commit() # can add_user touch the db?
# The whitelist set and the users in the db are now the same.
# From this point on, any user changes should be done simultaneously
# to the whitelist set and user db, unless the whitelist is empty (all users allowed).
def init_api_tokens(self):
"""Load predefined API tokens (for services) into database"""
db = self.db
for token, username in self.api_tokens.items():
username = self.authenticator.normalize_username(username)
if not self.authenticator.check_whitelist(username):
raise ValueError("Token username %r is not in whitelist" % username)
if not self.authenticator.validate_username(username):
raise ValueError("Token username %r is not valid" % username)
orm_token = orm.APIToken.find(db, token)
if orm_token is None:
user = orm.User.find(db, username)
user_created = False
if user is None:
user_created = True
self.log.debug("Adding user %r to database", username)
user = orm.User(name=username)
db.add(user)
db.commit()
for user in new_users:
yield gen.maybe_future(self.authenticator.add_user(user))
self.log.info("Adding API token for %s", username)
try:
user.new_api_token(token)
except Exception:
if user_created:
# don't allow bad tokens to create users
db.delete(user)
db.commit()
raise
else:
self.log.debug("Not duplicating token %s", orm_token)
db.commit()
@gen.coroutine
@@ -696,22 +855,21 @@ class JupyterHub(Application):
@gen.coroutine
def user_stopped(user):
status = yield user.spawner.poll()
self.log.warn("User %s server stopped with exit code: %s",
self.log.warning("User %s server stopped with exit code: %s",
user.name, status,
)
yield self.proxy.delete_user(user)
yield user.stop()
for user in db.query(orm.User):
for orm_user in db.query(orm.User):
self.users[orm_user.id] = user = User(orm_user, self.tornado_settings)
if not user.state:
# without spawner state, server isn't valid
user.server = None
user_summaries.append(_user_summary(user))
continue
self.log.debug("Loading state for %s from db", user.name)
user.spawner = spawner = self.spawner_class(
user=user, hub=self.hub, config=self.config, db=self.db,
)
spawner = user.spawner
status = yield spawner.poll()
if status is None:
self.log.info("%s still running", user.name)
@@ -721,7 +879,7 @@ class JupyterHub(Application):
# user not running. This is expected if server is None,
# but indicates the user's server died while the Hub wasn't running
# if user.server is defined.
log = self.log.warn if user.server else self.log.debug
log = self.log.warning if user.server else self.log.debug
log("%s not running.", user.name)
user.server = None
@@ -779,13 +937,34 @@ class JupyterHub(Application):
'--api-ip', self.proxy.api_server.ip,
'--api-port', str(self.proxy.api_server.port),
'--default-target', self.hub.server.host,
'--error-target', url_path_join(self.hub.server.url, 'error'),
]
if self.subdomain_host:
cmd.append('--host-routing')
if self.debug_proxy:
cmd.extend(['--log-level', 'debug'])
if self.ssl_key:
cmd.extend(['--ssl-key', self.ssl_key])
if self.ssl_cert:
cmd.extend(['--ssl-cert', self.ssl_cert])
if self.statsd_host:
cmd.extend([
'--statsd-host', self.statsd_host,
'--statsd-port', str(self.statsd_port),
'--statsd-prefix', self.statsd_prefix + '.chp'
])
# Require SSL to be used or `--no-ssl` to confirm no SSL on
if ' --ssl' not in ' '.join(cmd):
if self.confirm_no_ssl:
self.log.warning("Running JupyterHub without SSL."
" There better be SSL termination happening somewhere else...")
else:
self.log.error(
"Refusing to run JuptyterHub without SSL."
" If you are terminating SSL in another layer,"
" pass --no-ssl to tell JupyterHub to allow the proxy to listen on HTTP."
)
self.exit(1)
self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url)
self.log.debug("Proxy cmd: %s", cmd)
try:
@@ -826,15 +1005,19 @@ class JupyterHub(Application):
)
yield self.start_proxy()
self.log.info("Setting up routes on new proxy")
yield self.proxy.add_all_users()
yield self.proxy.add_all_users(self.users)
self.log.info("New proxy back up, and good to go")
def init_tornado_settings(self):
"""Set up the tornado settings dict."""
base_url = self.hub.server.base_url
jinja_options = dict(
autoescape=True,
)
jinja_options.update(self.jinja_environment_options)
jinja_env = Environment(
loader=FileSystemLoader(self.template_paths),
**self.jinja_environment_options
**jinja_options
)
login_url = self.authenticator.login_url(base_url)
@@ -848,6 +1031,8 @@ class JupyterHub(Application):
else:
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
subdomain_host = self.subdomain_host
domain = urlparse(subdomain_host).hostname
settings = dict(
log_function=log_request,
config=self.config,
@@ -870,10 +1055,15 @@ class JupyterHub(Application):
template_path=self.template_paths,
jinja2_env=jinja_env,
version_hash=version_hash,
subdomain_host=subdomain_host,
domain=domain,
statsd=self.statsd,
)
# allow configured settings to have priority
settings.update(self.tornado_settings)
self.tornado_settings = settings
# constructing users requires access to tornado_settings
self.tornado_settings['users'] = self.users
def init_tornado_application(self):
"""Instantiate the tornado Application object"""
@@ -895,7 +1085,7 @@ class JupyterHub(Application):
self.load_config_file(self.config_file)
self.init_logging()
if 'JupyterHubApp' in self.config:
self.log.warn("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
self.log.warning("Use JupyterHub in config, not JupyterHubApp. Outdated config:\n%s",
'\n'.join('JupyterHubApp.{key} = {value!r}'.format(key=key, value=value)
for key, value in self.config.JupyterHubApp.items()
)
@@ -910,9 +1100,10 @@ class JupyterHub(Application):
self.init_hub()
self.init_proxy()
yield self.init_users()
self.init_api_tokens()
self.init_tornado_settings()
yield self.init_spawners()
self.init_handlers()
self.init_tornado_settings()
self.init_tornado_application()
@gen.coroutine
@@ -923,7 +1114,7 @@ class JupyterHub(Application):
if self.cleanup_servers:
self.log.info("Cleaning up single-user servers...")
# request (async) process termination
for user in self.db.query(orm.User):
for uid, user in self.users.items():
if user.spawner is not None:
futures.append(user.stop())
else:
@@ -989,22 +1180,30 @@ class JupyterHub(Application):
def update_last_activity(self):
"""Update User.last_activity timestamps from the proxy"""
routes = yield self.proxy.get_routes()
users_count = 0
active_users_count = 0
for prefix, route in routes.items():
if 'user' not in route:
# not a user route, ignore it
continue
user = orm.User.find(self.db, route['user'])
if user is None:
self.log.warn("Found no user for route: %s", route)
self.log.warning("Found no user for route: %s", route)
continue
try:
dt = datetime.strptime(route['last_activity'], ISO8601_ms)
except Exception:
dt = datetime.strptime(route['last_activity'], ISO8601_s)
user.last_activity = max(user.last_activity, dt)
# FIXME: Make this configurable duration. 30 minutes for now!
if (datetime.now() - user.last_activity).total_seconds() < 30 * 60:
active_users_count += 1
users_count += 1
self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count)
self.db.commit()
yield self.proxy.check_routes(routes)
yield self.proxy.check_routes(self.users, routes)
@gen.coroutine
def start(self):
@@ -1039,7 +1238,7 @@ class JupyterHub(Application):
self.exit(1)
return
loop.add_callback(self.proxy.add_all_users)
loop.add_callback(self.proxy.add_all_users, self.users)
if self.proxy_process:
# only check / restart the proxy if we started it in the first place.

View File

@@ -1,41 +1,47 @@
"""Simple PAM authenticator"""
"""Base Authenticator class and the default PAM Authenticator"""
# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.
from grp import getgrnam
import pipes
import pwd
from subprocess import check_call, check_output, CalledProcessError
import re
from shutil import which
import sys
from subprocess import Popen, PIPE, STDOUT
from tornado import gen
import simplepam
import pamela
from traitlets.config import LoggingConfigurable
from traitlets import Bool, Set, Unicode, Any
from traitlets import Bool, Set, Unicode, Dict, Any, default, observe
from .handlers.login import LoginHandler
from .utils import url_path_join
from .traitlets import Command
class Authenticator(LoggingConfigurable):
"""A class for authentication.
The API is one method, `authenticate`, a tornado gen.coroutine.
The primary API is one method, `authenticate`, a tornado coroutine
for authenticating users.
"""
db = Any()
admin_users = Set(config=True,
admin_users = Set(
help="""set of usernames of admin users
If unspecified, only the user that launches the server will be admin.
"""
)
whitelist = Set(config=True,
).tag(config=True)
whitelist = Set(
help="""Username whitelist.
Use this to restrict which users can login.
If empty, allow any user to attempt login.
"""
)
).tag(config=True)
custom_html = Unicode('',
help="""HTML login form for custom handlers.
Override in form-based custom authenticators
@@ -49,6 +55,89 @@ class Authenticator(LoggingConfigurable):
"""
)
username_pattern = Unicode(
help="""Regular expression pattern for validating usernames.
If not defined: allow any username.
"""
).tag(config=True)
@observe('username_pattern')
def _username_pattern_changed(self, change):
if not change['new']:
self.username_regex = None
self.username_regex = re.compile(change['new'])
username_regex = Any()
def validate_username(self, username):
"""Validate a (normalized) username.
Return True if username is valid, False otherwise.
"""
if not self.username_regex:
return True
return bool(self.username_regex.match(username))
username_map = Dict(
help="""Dictionary mapping authenticator usernames to JupyterHub users.
Can be used to map OAuth service names to local users, for instance.
Used in normalize_username.
"""
).tag(config=True)
def normalize_username(self, username):
"""Normalize a username.
Override in subclasses if usernames should have some normalization.
Default: cast to lowercase, lookup in username_map.
"""
username = username.lower()
username = self.username_map.get(username, username)
return username
def check_whitelist(self, username):
"""Check a username against our whitelist.
Return True if username is allowed, False otherwise.
No whitelist means any username should be allowed.
Names are normalized *before* being checked against the whitelist.
"""
if not self.whitelist:
# No whitelist means any name is allowed
return True
return username in self.whitelist
@gen.coroutine
def get_authenticated_user(self, handler, data):
"""This is the outer API for authenticating a user.
This calls `authenticate`, which should be overridden in subclasses,
normalizes the username if any normalization should be done,
and then validates the name in the whitelist.
Subclasses should not need to override this method.
The various stages can be overridden separately:
- authenticate turns formdata into a username
- normalize_username normalizes the username
- check_whitelist checks against the user whitelist
"""
username = yield self.authenticate(handler, data)
if username is None:
return
username = self.normalize_username(username)
if not self.validate_username(username):
self.log.warning("Disallowing invalid username %r.", username)
return
if self.check_whitelist(username):
return username
else:
self.log.warning("User %r not in whitelist.", username)
return
@gen.coroutine
def authenticate(self, handler, data):
"""Authenticate a user with login form data.
@@ -56,14 +145,29 @@ class Authenticator(LoggingConfigurable):
This must be a tornado gen.coroutine.
It must return the username on successful authentication,
and return None on failed authentication.
Checking the whitelist is handled separately by the caller.
Args:
handler (tornado.web.RequestHandler): the current request handler
data (dict): The formdata of the login form.
The default form has 'username' and 'password' fields.
Return:
str: the username of the authenticated user
None: Authentication failed
"""
def check_whitelist(self, user):
def pre_spawn_start(self, user, spawner):
"""Hook called before spawning a user's server.
Can be used to do auth-related startup, e.g. opening PAM sessions.
"""
Return True if the whitelist is empty or user is in the whitelist.
def post_spawn_stop(self, user, spawner):
"""Hook called after stopping a user container.
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
"""
# Parens aren't necessary here, but they make this easier to parse.
return (not self.whitelist) or (user in self.whitelist)
def add_user(self, user):
"""Add a new user
@@ -71,8 +175,14 @@ class Authenticator(LoggingConfigurable):
By default, this just adds the user to the whitelist.
Subclasses may do more extensive things,
such as adding actual unix users.
such as adding actual unix users,
but they should call super to ensure the whitelist is updated.
Args:
user (User): The User wrapper object
"""
if not self.validate_username(user.name):
raise ValueError("Invalid username: %s" % user.name)
if self.whitelist:
self.whitelist.add(user.name)
@@ -80,46 +190,108 @@ class Authenticator(LoggingConfigurable):
"""Triggered when a user is deleted.
Removes the user from the whitelist.
Subclasses should call super to ensure the whitelist is updated.
Args:
user (User): The User wrapper object
"""
self.whitelist.discard(user.name)
def login_url(self, base_url):
"""Override to register a custom login handler"""
"""Override to register a custom login handler
Generally used in combination with get_handlers.
Args:
base_url (str): the base URL of the Hub (e.g. /hub/)
Returns:
str: The login URL, e.g. '/hub/login'
"""
return url_path_join(base_url, 'login')
def logout_url(self, base_url):
"""Override to register a custom logout handler"""
"""Override to register a custom logout handler.
Generally used in combination with get_handlers.
Args:
base_url (str): the base URL of the Hub (e.g. /hub/)
Returns:
str: The logout URL, e.g. '/hub/logout'
"""
return url_path_join(base_url, 'logout')
def get_handlers(self, app):
"""Return any custom handlers the authenticator needs to register
(e.g. for OAuth)
(e.g. for OAuth).
Args:
app (JupyterHub Application):
the application object, in case it needs to be accessed for info.
Returns:
list: list of ``('/url', Handler)`` tuples passed to tornado.
The Hub prefix is added to any URLs.
"""
return [
('/login', LoginHandler),
]
class LocalAuthenticator(Authenticator):
"""Base class for Authenticators that work with local *ix users
"""Base class for Authenticators that work with local Linux/UNIX users
Checks for local users, and can attempt to create them if they exist.
"""
create_system_users = Bool(False, config=True,
create_system_users = Bool(False,
help="""If a user is added that doesn't exist on the system,
should I try to create the system user?
"""
)
).tag(config=True)
add_user_cmd = Command(
help="""The command to use for creating users as a list of strings.
For each element in the list, the string USERNAME will be replaced with
the user's username. The username will also be appended as the final argument.
For Linux, the default value is:
['adduser', '-q', '--gecos', '""', '--disabled-password']
To specify a custom home directory, set this to:
['adduser', '-q', '--gecos', '""', '--home', '/customhome/USERNAME', '--disabled-password']
This will run the command:
adduser -q --gecos "" --home /customhome/river --disabled-password river
when the user 'river' is created.
"""
).tag(config=True)
@default('add_user_cmd')
def _add_user_cmd_default(self):
if sys.platform == 'darwin':
raise ValueError("I don't know how to create users on OS X")
elif which('pw'):
# Probably BSD
return ['pw', 'useradd', '-m']
else:
# This appears to be the Linux non-interactive adduser command:
return ['adduser', '-q', '--gecos', '""', '--disabled-password']
group_whitelist = Set(
config=True,
help="Automatically whitelist anyone in this group.",
)
def _group_whitelist_changed(self, name, old, new):
).tag(config=True)
@observe('group_whitelist')
def _group_whitelist_changed(self, change):
if self.whitelist:
self.log.warn(
self.log.warning(
"Ignoring username whitelist because group whitelist supplied!"
)
@@ -146,10 +318,7 @@ class LocalAuthenticator(Authenticator):
def add_user(self, user):
"""Add a new user
By default, this just adds the user to the whitelist.
Subclasses may do more extensive things,
such as adding actual unix users.
If self.create_system_users, the user will attempt to be created.
"""
user_exists = yield gen.maybe_future(self.system_user_exists(user))
if not user_exists:
@@ -170,34 +339,38 @@ class LocalAuthenticator(Authenticator):
else:
return True
@staticmethod
def add_system_user(user):
"""Create a new *ix user on the system. Works on FreeBSD and Linux, at least."""
def add_system_user(self, user):
"""Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least."""
name = user.name
for useradd in (
['pw', 'useradd', '-m'],
['useradd', '-m'],
):
try:
check_output(['which', useradd[0]])
except CalledProcessError:
continue
else:
break
else:
raise RuntimeError("I don't know how to add users on this system.")
check_call(useradd + [name])
cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name]
self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
p = Popen(cmd, stdout=PIPE, stderr=STDOUT)
p.wait()
if p.returncode:
err = p.stdout.read().decode('utf8', 'replace')
raise RuntimeError("Failed to create system user %s: %s" % (name, err))
class PAMAuthenticator(LocalAuthenticator):
"""Authenticate local *ix users with PAM"""
encoding = Unicode('utf8', config=True,
"""Authenticate local Linux/UNIX users with PAM"""
encoding = Unicode('utf8',
help="""The encoding to use for PAM"""
)
service = Unicode('login', config=True,
).tag(config=True)
service = Unicode('login',
help="""The PAM service to use for authentication."""
)
).tag(config=True)
open_sessions = Bool(True,
help="""Whether to open PAM sessions when spawners are started.
This may trigger things like mounting shared filsystems,
loading credentials, etc. depending on system configuration,
but it does not always work.
It can be disabled with::
c.PAMAuthenticator.open_sessions = False
"""
).tag(config=True)
@gen.coroutine
def authenticate(self, handler, data):
@@ -206,12 +379,35 @@ class PAMAuthenticator(LocalAuthenticator):
Return None otherwise.
"""
username = data['username']
if not self.check_whitelist(username):
return
# simplepam wants bytes, not unicode
# see simplepam#3
busername = username.encode(self.encoding)
bpassword = data['password'].encode(self.encoding)
if simplepam.authenticate(busername, bpassword, service=self.service):
try:
pamela.authenticate(username, data['password'], service=self.service)
except pamela.PAMError as e:
if handler is not None:
self.log.warning("PAM Authentication failed (%s@%s): %s", username, handler.request.remote_ip, e)
else:
self.log.warning("PAM Authentication failed: %s", e)
else:
return username
def pre_spawn_start(self, user, spawner):
"""Open PAM session for user"""
if not self.open_sessions:
return
try:
pamela.open_session(user.name, service=self.service)
except pamela.PAMError as e:
self.log.warning("Failed to open PAM session for %s: %s", user.name, e)
self.log.warning("Disabling PAM sessions from now on.")
self.open_sessions = False
def post_spawn_stop(self, user, spawner):
"""Close PAM session for user"""
if not self.open_sessions:
return
try:
pamela.close_session(user.name, service=self.service)
except pamela.PAMError as e:
self.log.warning("Failed to close PAM session for %s: %s", user.name, e)
self.log.warning("Disabling PAM sessions from now on.")
self.open_sessions = False

13
jupyterhub/emptyclass.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Simple empty class that returns itself for all functions called on it.
This allows us to call any method of any name on this, and it'll return another
instance of itself that'll allow any method to be called on it.
Primarily used to mock out the statsd client when statsd is not being used
"""
class EmptyClass:
def empty_function(self, *args, **kwargs):
return self
def __getattr__(self, attr):
return self.empty_function

View File

@@ -4,7 +4,7 @@
# Distributed under the terms of the Modified BSD License.
import re
from datetime import datetime, timedelta
from datetime import timedelta
from http.client import responses
from jinja2 import TemplateNotFound
@@ -16,6 +16,7 @@ from tornado.web import RequestHandler
from tornado import gen, web
from .. import orm
from ..user import User
from ..spawner import LocalProcessSpawner
from ..utils import url_path_join
@@ -50,10 +51,22 @@ class BaseHandler(RequestHandler):
def version_hash(self):
return self.settings.get('version_hash', '')
@property
def subdomain_host(self):
return self.settings.get('subdomain_host', '')
@property
def domain(self):
return self.settings['domain']
@property
def db(self):
return self.settings['db']
@property
def users(self):
return self.settings.setdefault('users', {})
@property
def hub(self):
return self.settings['hub']
@@ -62,6 +75,10 @@ class BaseHandler(RequestHandler):
def proxy(self):
return self.settings['proxy']
@property
def statsd(self):
return self.settings['statsd']
@property
def authenticator(self):
return self.settings.get('authenticator', None)
@@ -141,17 +158,24 @@ class BaseHandler(RequestHandler):
if cookie_id is None:
if self.get_cookie(cookie_name):
self.log.warn("Invalid or expired cookie token")
self.log.warning("Invalid or expired cookie token")
clear()
return
cookie_id = cookie_id.decode('utf8', 'replace')
user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
user = self._user_from_orm(u)
if user is None:
self.log.warn("Invalid cookie token")
self.log.warning("Invalid cookie token")
# have cookie, but it's not valid. Clear it and start over.
clear()
return user
def _user_from_orm(self, orm_user):
"""return User wrapper from orm.User object"""
if orm_user is None:
return
return self.users[orm_user]
def get_current_user_cookie(self):
"""get_current_user from a cookie token"""
return self._user_for_cookie(self.hub.server.cookie_name)
@@ -168,55 +192,64 @@ class BaseHandler(RequestHandler):
return None if no such user
"""
return orm.User.find(self.db, name)
orm_user = orm.User.find(db=self.db, name=name)
return self._user_from_orm(orm_user)
def user_from_username(self, username):
"""Get ORM User for username"""
"""Get User for username, creating if it doesn't exist"""
user = self.find_user(username)
if user is None:
user = orm.User(name=username)
self.db.add(user)
# not found, create and register user
u = orm.User(name=username)
self.db.add(u)
self.db.commit()
user = self._user_from_orm(u)
self.authenticator.add_user(user)
return user
def clear_login_cookie(self):
def clear_login_cookie(self, name=None):
if name is None:
user = self.get_current_user()
else:
user = self.find_user(name)
kwargs = {}
if self.subdomain_host:
kwargs['domain'] = self.domain
if user and user.server:
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
def _set_user_cookie(self, user, server):
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure': True}
else:
kwargs = {}
if self.subdomain_host:
kwargs['domain'] = self.domain
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
self.set_secure_cookie(
server.cookie_name,
user.cookie_id,
path=server.base_url,
**kwargs
)
def set_server_cookie(self, user):
"""set the login cookie for the single-user server"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
user.server.cookie_name,
user.cookie_id,
path=user.server.base_url,
**kwargs
)
self._set_user_cookie(user, user.server)
def set_hub_cookie(self, user):
"""set the login cookie for the Hub"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
self.hub.server.cookie_name,
user.cookie_id,
path=self.hub.server.base_url,
**kwargs
)
self._set_user_cookie(user, self.hub.server)
def set_login_cookie(self, user):
"""Set login cookies for the Hub and single-user server."""
if self.subdomain_host and not self.request.host.startswith(self.domain):
self.log.warning(
"Possibly setting cookie on wrong domain: %s != %s",
self.request.host, self.domain)
# create and set a new cookie token for the single-user server
if user.server:
self.set_server_cookie(user)
@@ -229,7 +262,7 @@ class BaseHandler(RequestHandler):
def authenticate(self, data):
auth = self.authenticator
if auth is not None:
result = yield auth.authenticate(self, data)
result = yield auth.get_authenticated_user(self, data)
return result
else:
self.log.error("No authentication function, login is impossible!")
@@ -252,17 +285,13 @@ class BaseHandler(RequestHandler):
return self.settings.get('spawner_class', LocalProcessSpawner)
@gen.coroutine
def spawn_single_user(self, user):
def spawn_single_user(self, user, options=None):
if user.spawn_pending:
raise RuntimeError("Spawn already pending for: %s" % user.name)
tic = IOLoop.current().time()
f = user.spawn(
spawner_class=self.spawner_class,
base_url=self.base_url,
hub=self.hub,
config=self.config,
)
f = user.spawn(options)
@gen.coroutine
def finish_user_spawn(f=None):
"""Finish the user spawn by registering listeners and notifying the proxy.
@@ -275,6 +304,7 @@ class BaseHandler(RequestHandler):
return
toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
self.statsd.timing('spawner.success', (toc - tic) * 1000)
yield self.proxy.add_user(user)
user.spawner.add_poll_callback(self.user_stopped, user)
@@ -282,12 +312,25 @@ class BaseHandler(RequestHandler):
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
except gen.TimeoutError:
if user.spawn_pending:
# hit timeout, but spawn is still pending
self.log.warn("User %s server is slow to start", user.name)
# still in Spawner.start, which is taking a long time
# we shouldn't poll while spawn_pending is True
self.log.warning("User %s server is slow to start", user.name)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
raise
# start has finished, but the server hasn't come up
# check if the server died while we were waiting
status = yield user.spawner.poll()
if status is None:
# hit timeout, but server's running. Hope that it'll show up soon enough,
# though it's possible that it started at the wrong URL
self.log.warning("User %s server is slow to become responsive", user.name)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
toc = IOLoop.current().time()
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
else:
yield finish_user_spawn()
@@ -297,7 +340,7 @@ class BaseHandler(RequestHandler):
status = yield user.spawner.poll()
if status is None:
status = 'unknown'
self.log.warn("User %s server stopped, with exit code: %s",
self.log.warning("User %s server stopped, with exit code: %s",
user.name, status,
)
yield self.proxy.delete_user(user)
@@ -328,7 +371,7 @@ class BaseHandler(RequestHandler):
except gen.TimeoutError:
if user.stop_pending:
# hit timeout, but stop is still pending
self.log.warn("User %s server is slow to stop", user.name)
self.log.warning("User %s server is slow to stop", user.name)
# schedule finish for when the server finishes stopping
IOLoop.current().add_future(f, finish_stop)
else:
@@ -367,6 +410,7 @@ class BaseHandler(RequestHandler):
"""render custom error pages"""
exc_info = kwargs.get('exc_info')
message = ''
exception = None
status_message = responses.get(status_code, 'Unknown HTTP Error')
if exc_info:
exception = exc_info[1]
@@ -419,18 +463,23 @@ class PrefixRedirectHandler(BaseHandler):
class UserSpawnHandler(BaseHandler):
"""Requests to /user/name handled by the Hub
should result in spawning the single-user server and
being redirected to the original.
"""Redirect requests to /user/name/* handled by the Hub.
If logged in, spawn a single-user server and redirect request.
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
redirect her to /user/alice/notebooks/mynotebook.ipynb, which should
in turn call this function.
"""
@gen.coroutine
def get(self, name):
def get(self, name, user_path):
current_user = self.get_current_user()
if current_user and current_user.name == name:
# logged in, spawn the server
# logged in as correct user, spawn the server
if current_user.spawner:
if current_user.spawn_pending:
# spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1)
html = self.render_template("spawn_pending.html", user=current_user)
self.finish(html)
return
@@ -438,32 +487,47 @@ class UserSpawnHandler(BaseHandler):
# spawn has supposedly finished, check on the status
status = yield current_user.spawner.poll()
if status is not None:
yield self.spawn_single_user(current_user)
if current_user.spawner.options_form:
self.redirect(url_path_join(self.hub.server.base_url, 'spawn'))
return
else:
yield self.spawn_single_user(current_user)
# set login cookie anew
self.set_login_cookie(current_user)
without_prefix = self.request.uri[len(self.hub.server.base_url):]
target = url_path_join(self.base_url, without_prefix)
if self.subdomain_host:
target = current_user.host + target
self.redirect(target)
self.statsd.incr('redirects.user_after_login')
elif current_user:
# logged in as a different user, redirect
self.statsd.incr('redirects.user_to_user', 1)
target = url_path_join(current_user.url, user_path or '')
self.redirect(target)
else:
# not logged in to the right user,
# clear any cookies and reload (will redirect to login)
# not logged in, clear any cookies and reload
self.statsd.incr('redirects.user_to_login', 1)
self.clear_login_cookie()
self.redirect(url_concat(
self.settings['login_url'],
{'next': self.request.uri,
}))
{'next': self.request.uri},
))
class CSPReportHandler(BaseHandler):
'''Accepts a content security policy violation report'''
@web.authenticated
def post(self):
'''Log a content security policy violation report'''
self.log.warn("Content security violation: %s",
self.request.body.decode('utf8', 'replace'))
self.log.warning(
"Content security violation: %s",
self.request.body.decode('utf8', 'replace')
)
# Report it to statsd as well
self.statsd.incr('csp_report')
default_handlers = [
(r'/user/([^/]+)/?.*', UserSpawnHandler),
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
(r'/security/csp-report', CSPReportHandler),
]

View File

@@ -16,6 +16,10 @@ class LogoutHandler(BaseHandler):
if user:
self.log.info("User logged out: %s", user.name)
self.clear_login_cookie()
for name in user.other_user_cookies:
self.clear_login_cookie(name)
user.other_user_cookies = set([])
self.statsd.incr('logout')
self.redirect(self.hub.server.base_url, permanent=False)
@@ -27,10 +31,12 @@ class LoginHandler(BaseHandler):
next=url_escape(self.get_argument('next', default='')),
username=username,
login_error=login_error,
custom_login_form=self.authenticator.custom_html,
custom_html=self.authenticator.custom_html,
login_url=self.settings['login_url'],
)
def get(self):
self.statsd.incr('login.request')
next_url = self.get_argument('next', '')
if not next_url.startswith('/'):
# disallow non-absolute next URLs (e.g. full URLs)
@@ -39,7 +45,7 @@ class LoginHandler(BaseHandler):
if user:
if not next_url:
if user.running:
next_url = user.server.base_url
next_url = user.url
else:
next_url = self.hub.server.base_url
# set new login cookie
@@ -57,15 +63,19 @@ class LoginHandler(BaseHandler):
for arg in self.request.arguments:
data[arg] = self.get_argument(arg)
username = data['username']
authorized = yield self.authenticate(data)
if authorized:
auth_timer = self.statsd.timer('login.authenticate').start()
username = yield self.authenticate(data)
auth_timer.stop(send=False)
if username:
self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms)
user = self.user_from_username(username)
already_running = False
if user.spawner:
status = yield user.spawner.poll()
already_running = (status == None)
if not already_running:
if not already_running and not user.spawner.options_form:
yield self.spawn_single_user(user)
self.set_login_cookie(user)
next_url = self.get_argument('next', default='')
@@ -75,7 +85,9 @@ class LoginHandler(BaseHandler):
self.redirect(next_url)
self.log.info("User logged in: %s", username)
else:
self.log.debug("Failed login for %s", username)
self.statsd.incr('login.failure')
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
html = self._render(
login_error='Invalid username or password',
username=username,
@@ -83,7 +95,10 @@ class LoginHandler(BaseHandler):
self.finish(html)
# Only logout is a default handler.
# /login renders the login page or the "Login with..." link,
# so it should always be registered.
# /logout clears cookies.
default_handlers = [
(r"/login", LoginHandler),
(r"/logout", LogoutHandler),
]

View File

@@ -3,7 +3,10 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from tornado import web
from http.client import responses
from jinja2 import TemplateNotFound
from tornado import web, gen
from .. import orm
from ..utils import admin_only, url_path_join
@@ -24,28 +27,90 @@ class RootHandler(BaseHandler):
user = self.get_current_user()
if user:
if user.running:
url = user.server.base_url
url = user.url
self.log.debug("User is running: %s", url)
self.set_login_cookie(user) # set cookie
else:
url = url_path_join(self.hub.server.base_url, 'home')
self.redirect(url, permanent=False)
self.log.debug("User is not running: %s", url)
self.redirect(url)
return
html = self.render_template('login.html',
login_url=self.settings['login_url'],
custom_html=self.authenticator.custom_html,
)
self.finish(html)
url = url_path_join(self.hub.server.base_url, 'login')
self.redirect(url)
class HomeHandler(BaseHandler):
"""Render the user's home page."""
@web.authenticated
@gen.coroutine
def get(self):
user = self.get_current_user()
if user.running:
# trigger poll_and_notify event in case of a server that died
yield user.spawner.poll_and_notify()
html = self.render_template('home.html',
user=self.get_current_user(),
user=user,
)
self.finish(html)
class SpawnHandler(BaseHandler):
"""Handle spawning of single-user servers via form.
GET renders the form, POST handles form submission.
Only enabled when Spawner.options_form is defined.
"""
def _render_form(self, message=''):
user = self.get_current_user()
return self.render_template('spawn.html',
user=user,
spawner_options_form=user.spawner.options_form,
error_message=message,
)
@web.authenticated
def get(self):
"""GET renders form for spawning with user-specified options"""
user = self.get_current_user()
if user.running:
url = user.url
self.log.debug("User is running: %s", url)
self.redirect(url)
return
if user.spawner.options_form:
self.finish(self._render_form())
else:
# not running, no form. Trigger spawn.
self.redirect(user.url)
@web.authenticated
@gen.coroutine
def post(self):
"""POST spawns with user-specified options"""
user = self.get_current_user()
if user.running:
url = user.url
self.log.warning("User is already running: %s", url)
self.redirect(url)
return
form_options = {}
for key, byte_list in self.request.body_arguments.items():
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
for key, byte_list in self.request.files.items():
form_options["%s_file"%key] = byte_list
try:
options = user.spawner.options_from_form(form_options)
yield self.spawn_single_user(user, options=options)
except Exception as e:
self.log.error("Failed to spawn single-user server with form", exc_info=True)
self.finish(self._render_form(str(e)))
return
self.set_login_cookie(user)
url = user.url
self.redirect(url)
class AdminHandler(BaseHandler):
"""Render the admin page."""
@@ -66,10 +131,10 @@ class AdminHandler(BaseHandler):
orders = self.get_arguments('order')
for bad in set(sorts).difference(available):
self.log.warn("ignoring invalid sort: %r", bad)
self.log.warning("ignoring invalid sort: %r", bad)
sorts.remove(bad)
for bad in set(orders).difference({'asc', 'desc'}):
self.log.warn("ignoring invalid order: %r", bad)
self.log.warning("ignoring invalid order: %r", bad)
orders.remove(bad)
# add default sort as secondary
@@ -89,7 +154,8 @@ class AdminHandler(BaseHandler):
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
users = self.db.query(orm.User).order_by(*ordered)
running = users.filter(orm.User.server != None)
users = [ self._user_from_orm(u) for u in users ]
running = [ u for u in users if u.running ]
html = self.render_template('admin.html',
user=self.get_current_user(),
@@ -101,8 +167,43 @@ class AdminHandler(BaseHandler):
self.finish(html)
class ProxyErrorHandler(BaseHandler):
"""Handler for rendering proxy error pages"""
def get(self, status_code_s):
status_code = int(status_code_s)
status_message = responses.get(status_code, 'Unknown HTTP Error')
# build template namespace
hub_home = url_path_join(self.hub.server.base_url, 'home')
message_html = ''
if status_code == 503:
message_html = ' '.join([
"Your server appears to be down.",
"Try restarting it <a href='%s'>from the hub</a>" % hub_home
])
ns = dict(
status_code=status_code,
status_message=status_message,
message_html=message_html,
logo_url=hub_home,
)
self.set_header('Content-Type', 'text/html')
# render the template
try:
html = self.render_template('%s.html' % status_code, **ns)
except TemplateNotFound:
self.log.debug("No template for %d", status_code)
html = self.render_template('error.html', **ns)
self.write(html)
default_handlers = [
(r'/', RootHandler),
(r'/home', HomeHandler),
(r'/admin', AdminHandler),
(r'/spawn', SpawnHandler),
(r'/error/(\d+)', ProxyErrorHandler),
]

View File

@@ -1,6 +1,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os
from tornado.web import StaticFileHandler
class CacheControlStaticFilesHandler(StaticFileHandler):
@@ -15,3 +16,13 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
if "v" not in self.request.arguments:
self.add_header("Cache-Control", "no-cache")
class LogoHandler(StaticFileHandler):
"""A singular handler for serving the logo."""
def get(self):
return super().get('')
@classmethod
def get_absolute_path(cls, root, path):
"""We only serve one file, ignore relative path"""
return os.path.abspath(root)

View File

@@ -3,17 +3,14 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta
import errno
from datetime import datetime
import json
import socket
from urllib.parse import quote
from tornado import gen
from tornado.log import app_log
from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
from sqlalchemy.types import TypeDecorator, VARCHAR
from sqlalchemy.types import TypeDecorator, TEXT
from sqlalchemy import (
inspect,
Column, Integer, ForeignKey, Unicode, Boolean,
@@ -27,7 +24,7 @@ from sqlalchemy import create_engine
from .utils import (
random_port, url_path_join, wait_for_server, wait_for_http_server,
new_token, hash_token, compare_token,
new_token, hash_token, compare_token, can_connect,
)
@@ -40,7 +37,7 @@ class JSONDict(TypeDecorator):
"""
impl = VARCHAR
impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
@@ -65,11 +62,11 @@ class Server(Base):
"""
__tablename__ = 'servers'
id = Column(Integer, primary_key=True)
proto = Column(Unicode, default='http')
ip = Column(Unicode, default='')
proto = Column(Unicode(15), default='http')
ip = Column(Unicode(255), default='') # could also be a DNS name
port = Column(Integer, default=random_port)
base_url = Column(Unicode, default='/')
cookie_name = Column(Unicode, default='cookie')
base_url = Column(Unicode(255), default='/')
cookie_name = Column(Unicode(255), default='cookie')
def __repr__(self):
return "<Server(%s:%s)>" % (self.ip, self.port)
@@ -79,7 +76,7 @@ class Server(Base):
ip = self.ip
if ip in {'', '0.0.0.0'}:
# when listening on all interfaces, connect to localhost
ip = 'localhost'
ip = '127.0.0.1'
return "{proto}://{ip}:{port}".format(
proto=self.proto,
ip=ip,
@@ -101,7 +98,7 @@ class Server(Base):
since it can be non-connectable value, such as '', meaning all interfaces.
"""
if self.ip in {'', '0.0.0.0'}:
return self.url.replace('localhost', self.ip or '*', 1)
return self.url.replace('127.0.0.1', self.ip or '*', 1)
return self.url
@gen.coroutine
@@ -110,19 +107,11 @@ class Server(Base):
if http:
yield wait_for_http_server(self.url, timeout=timeout)
else:
yield wait_for_server(self.ip or 'localhost', self.port, timeout=timeout)
yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
def is_up(self):
"""Is the server accepting connections?"""
try:
socket.create_connection((self.ip or 'localhost', self.port))
except socket.error as e:
if e.errno == errno.ECONNREFUSED:
return False
else:
raise
else:
return True
return can_connect(self.ip or '127.0.0.1', self.port)
class Proxy(Base):
@@ -167,10 +156,14 @@ class Proxy(Base):
def add_user(self, user, client=None):
"""Add a user's server to the proxy table."""
self.log.info("Adding user %s to proxy %s => %s",
user.name, user.server.base_url, user.server.host,
user.name, user.proxy_path, user.server.host,
)
yield self.api_request(user.server.base_url,
if user.spawn_pending:
raise RuntimeError(
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
yield self.api_request(user.proxy_path,
method='POST',
body=dict(
target=user.server.host,
@@ -183,26 +176,11 @@ class Proxy(Base):
def delete_user(self, user, client=None):
"""Remove a user's server to the proxy table."""
self.log.info("Removing user %s from proxy", user.name)
yield self.api_request(user.server.base_url,
yield self.api_request(user.proxy_path,
method='DELETE',
client=client,
)
@gen.coroutine
def add_all_users(self):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for user in db.query(User):
if (user.server):
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def get_routes(self, client=None):
"""Fetch the proxy's routes"""
@@ -210,17 +188,42 @@ class Proxy(Base):
return json.loads(resp.body.decode('utf8', 'replace'))
@gen.coroutine
def check_routes(self, routes=None):
"""Check that all users are properly"""
def add_all_users(self, user_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for orm_user in db.query(User):
user = user_dict[orm_user]
if user.running:
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def check_routes(self, user_dict, routes=None):
"""Check that all users are properly routed on the proxy"""
if not routes:
routes = yield self.get_routes()
have_routes = { r['user'] for r in routes.values() if 'user' in r }
futures = []
db = inspect(self).session
for user in db.query(User).filter(User.server != None):
for orm_user in db.query(User).filter(User.server != None):
user = user_dict[orm_user]
if not user.running:
# Don't add users to the proxy that haven't finished starting
continue
if user.server is None:
# This should never be True, but seems to be on rare occasion.
# catch filter bug, either in sqlalchemy or my understanding of its behavior
self.log.error("User %s has no server, but wasn't filtered out.", user)
continue
if user.name not in have_routes:
self.log.warn("Adding missing route for %s", user.name)
self.log.warning("Adding missing route for %s (%s)", user.name, user.server)
futures.append(self.add_user(user))
for f in futures:
yield f
@@ -239,6 +242,7 @@ class Hub(Base):
id = Column(Integer, primary_key=True)
_server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id)
host = ''
@property
def api_url(self):
@@ -271,8 +275,8 @@ class User(Base):
used for restoring state of a Spawner.
"""
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(Unicode)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Unicode(1023))
# should we allow multiple servers per user?
_server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id)
@@ -280,11 +284,10 @@ class User(Base):
last_activity = Column(DateTime, default=datetime.utcnow)
api_tokens = relationship("APIToken", backref="user")
cookie_id = Column(Unicode, default=new_token)
cookie_id = Column(Unicode(1023), default=new_token)
state = Column(JSONDict)
spawner = None
spawn_pending = False
stop_pending = False
other_user_cookies = set([])
def __repr__(self):
if self.server:
@@ -300,25 +303,21 @@ class User(Base):
name=self.name,
)
@property
def escaped_name(self):
"""My name, escaped for use in URLs, cookies, etc."""
return quote(self.name, safe='@')
def new_api_token(self, token=None):
"""Create a new API token
@property
def running(self):
"""property for whether a user has a running server"""
if self.spawner is None:
return False
if self.server is None:
return False
return True
def new_api_token(self):
"""Create a new API token"""
If `token` is given, load that token.
"""
assert self.id is not None
db = inspect(self).session
if token is None:
token = new_token()
else:
if len(token) < 8:
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
found = APIToken.find(db, token)
if found:
raise ValueError("Collision on token: %s..." % token[:4])
orm_token = APIToken(user_id=self.id)
orm_token.token = token
db.add(orm_token)
@@ -333,115 +332,6 @@ class User(Base):
"""
return db.query(cls).filter(cls.name==name).first()
@gen.coroutine
def spawn(self, spawner_class, base_url='/', hub=None, config=None):
"""Start the user's spawner"""
db = inspect(self).session
if hub is None:
hub = db.query(Hub).first()
self.server = Server(
cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')),
base_url=url_path_join(base_url, 'user', self.escaped_name),
)
db.add(self.server)
db.commit()
api_token = self.new_api_token()
db.commit()
spawner = self.spawner = spawner_class(
config=config,
user=self,
hub=hub,
db=db,
)
# we are starting a new server, make sure it doesn't restore state
spawner.clear_state()
spawner.api_token = api_token
self.spawn_pending = True
# wait for spawner.start to return
try:
f = spawner.start()
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
except Exception as e:
if isinstance(e, gen.TimeoutError):
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
user=self.name, s=spawner.start_timeout,
))
e.reason = 'timeout'
else:
self.log.error("Unhandled error starting {user}'s server: {error}".format(
user=self.name, error=e,
))
e.reason = 'error'
try:
yield self.stop()
except Exception:
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
user=self.name,
), exc_info=True)
# raise original exception
raise e
spawner.start_polling()
# store state
self.state = spawner.get_state()
self.last_activity = datetime.utcnow()
db.commit()
try:
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
except Exception as e:
if isinstance(e, TimeoutError):
self.log.warn(
"{user}'s server never showed up at {url} "
"after {http_timeout} seconds. Giving up".format(
user=self.name,
url=self.server.url,
http_timeout=spawner.http_timeout,
)
)
e.reason = 'timeout'
else:
e.reason = 'error'
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=self.server.url, error=e,
))
try:
yield self.stop()
except Exception:
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
user=self.name,
), exc_info=True)
# raise original TimeoutError
raise e
self.spawn_pending = False
return self
@gen.coroutine
def stop(self):
"""Stop the user's spawner
and cleanup after it.
"""
self.spawn_pending = False
if self.spawner is None:
return
self.spawner.stop_polling()
self.stop_pending = True
try:
status = yield self.spawner.poll()
if status is None:
yield self.spawner.stop()
self.spawner.clear_state()
self.state = self.spawner.get_state()
self.server = None
inspect(self).session.commit()
finally:
self.stop_pending = False
class APIToken(Base):
"""An API token"""
__tablename__ = 'api_tokens'
@@ -451,8 +341,8 @@ class APIToken(Base):
return Column(Integer, ForeignKey('users.id'))
id = Column(Integer, primary_key=True)
hashed = Column(Unicode)
prefix = Column(Unicode)
hashed = Column(Unicode(1023))
prefix = Column(Unicode(1023))
prefix_length = 4
algorithm = "sha512"
rounds = 16384
@@ -498,6 +388,8 @@ def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
"""Create a new session at url"""
if url.startswith('sqlite'):
kwargs.setdefault('connect_args', {'check_same_thread': False})
elif url.startswith('mysql'):
kwargs.setdefault('pool_recycle', 60)
if url.endswith(':memory:'):
# If we're using an in-memory database, ensure that only one connection

View File

@@ -1,227 +0,0 @@
#!/usr/bin/env python3
"""Extend regular notebook server to be aware of multiuser things."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os
try:
from urllib.parse import quote
except ImportError:
# PY2 Compat
from urllib import quote
import requests
from jinja2 import ChoiceLoader, FunctionLoader
from tornado import ioloop
from tornado.web import HTTPError
from IPython.utils.traitlets import (
Integer,
Unicode,
CUnicode,
)
from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases
from IPython.html.auth.login import LoginHandler
from IPython.html.auth.logout import LogoutHandler
from IPython.html.utils import url_path_join
from distutils.version import LooseVersion as V
import IPython
if V(IPython.__version__) < V('3.0'):
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
# Define two methods to attach to AuthenticatedHandler,
# which authenticate via the central auth server.
class JupyterHubLoginHandler(LoginHandler):
@staticmethod
def login_available(settings):
return True
@staticmethod
def verify_token(self, cookie_name, encrypted_cookie):
"""method for token verification"""
cookie_cache = self.settings['cookie_cache']
if encrypted_cookie in cookie_cache:
# we've seen this token before, don't ask upstream again
return cookie_cache[encrypted_cookie]
hub_api_url = self.settings['hub_api_url']
hub_api_key = self.settings['hub_api_key']
r = requests.get(url_path_join(
hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''),
),
headers = {'Authorization' : 'token %s' % hub_api_key},
)
if r.status_code == 404:
data = None
elif r.status_code == 403:
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
elif r.status_code >= 500:
self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
raise HTTPError(502, "Failed to check authorization (upstream problem)")
elif r.status_code >= 400:
self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Failed to check authorization")
else:
data = r.json()
cookie_cache[encrypted_cookie] = data
return data
@staticmethod
def get_user(self):
"""alternative get_current_user to query the central server"""
# only allow this to be called once per handler
# avoids issues if an error is raised,
# since this may be called again when trying to render the error page
if hasattr(self, '_cached_user'):
return self._cached_user
self._cached_user = None
my_user = self.settings['user']
encrypted_cookie = self.get_cookie(self.cookie_name)
if encrypted_cookie:
auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie)
if not auth_data:
# treat invalid token the same as no token
return None
user = auth_data['name']
if user == my_user:
self._cached_user = user
return user
else:
return None
else:
self.log.debug("No token cookie")
return None
class JupyterHubLogoutHandler(LogoutHandler):
def get(self):
self.redirect(url_path_join(self.settings['hub_prefix'], 'logout'))
# register new hub related command-line aliases
aliases = dict(notebook_aliases)
aliases.update({
'user' : 'SingleUserNotebookApp.user',
'cookie-name': 'SingleUserNotebookApp.cookie_name',
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
'base-url': 'SingleUserNotebookApp.base_url',
})
page_template = """
{% extends "templates/page.html" %}
{% block header_buttons %}
{{super()}}
<a href='{{hub_control_panel_url}}'
class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;'
>
Control Panel</a>
{% endblock %}
"""
class SingleUserNotebookApp(NotebookApp):
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
user = CUnicode(config=True)
def _user_changed(self, name, old, new):
self.log.name = new
cookie_name = Unicode(config=True)
hub_prefix = Unicode(config=True)
hub_api_url = Unicode(config=True)
aliases = aliases
open_browser = False
trust_xheaders = True
login_handler_class = JupyterHubLoginHandler
logout_handler_class = JupyterHubLogoutHandler
cookie_cache_lifetime = Integer(
config=True,
default_value=300,
allow_none=True,
help="""
Time, in seconds, that we cache a validated cookie before requiring
revalidation with the hub.
""",
)
def _log_datefmt_default(self):
"""Exclude date from default date format"""
return "%Y-%m-%d %H:%M:%S"
def _log_format_default(self):
"""override default log format to include time"""
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
def _confirm_exit(self):
# disable the exit confirmation for background notebook processes
ioloop.IOLoop.instance().stop()
def _clear_cookie_cache(self):
self.log.debug("Clearing cookie cache")
self.tornado_settings['cookie_cache'].clear()
def start(self):
# Start a PeriodicCallback to clear cached cookies. This forces us to
# revalidate our user with the Hub at least every
# `cookie_cache_lifetime` seconds.
if self.cookie_cache_lifetime:
ioloop.PeriodicCallback(
self._clear_cookie_cache,
self.cookie_cache_lifetime * 1e3,
).start()
super(SingleUserNotebookApp, self).start()
def init_webapp(self):
# load the hub related settings into the tornado settings dict
env = os.environ
s = self.tornado_settings
s['cookie_cache'] = {}
s['user'] = self.user
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
s['hub_prefix'] = self.hub_prefix
s['cookie_name'] = self.cookie_name
s['login_url'] = self.hub_prefix
s['hub_api_url'] = self.hub_api_url
s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
super(SingleUserNotebookApp, self).init_webapp()
self.patch_templates()
def patch_templates(self):
"""Patch page templates to add Hub-related buttons"""
env = self.web_app.settings['jinja2_env']
env.globals['hub_control_panel_url'] = \
url_path_join(self.hub_prefix, 'home')
# patch jinja env loading to modify page template
def get_page(name):
if name == 'page.html':
return page_template
orig_loader = env.loader
env.loader = ChoiceLoader([
FunctionLoader(get_page),
orig_loader,
])
def main():
return SingleUserNotebookApp.launch_instance()
if __name__ == "__main__":
main()

View File

@@ -7,26 +7,24 @@ import errno
import os
import pipes
import pwd
import re
import signal
import sys
import grp
from subprocess import Popen, check_output, PIPE, CalledProcessError
from tempfile import TemporaryDirectory
import warnings
from subprocess import Popen
from tempfile import mkdtemp
from tornado import gen
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.ioloop import PeriodicCallback
from traitlets.config import LoggingConfigurable
from traitlets import (
Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode,
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
)
from .traitlets import Command
from .utils import random_port
NUM_PAT = re.compile(r'\d+')
class Spawner(LoggingConfigurable):
"""Base class for spawning single-user notebook servers.
@@ -42,38 +40,70 @@ class Spawner(LoggingConfigurable):
db = Any()
user = Any()
hub = Any()
authenticator = Any()
api_token = Unicode()
ip = Unicode('localhost', config=True,
ip = Unicode('127.0.0.1',
help="The IP address (or hostname) the single-user server should listen on"
)
start_timeout = Integer(60, config=True,
).tag(config=True)
start_timeout = Integer(60,
help="""Timeout (in seconds) before giving up on the spawner.
This is the timeout for start to return, not the timeout for the server to respond.
Callers of spawner.start will assume that startup has failed if it takes longer than this.
start should return when the server process is started and its location is known.
"""
)
).tag(config=True)
http_timeout = Integer(
30, config=True,
http_timeout = Integer(30,
help="""Timeout (in seconds) before giving up on a spawned HTTP server
Once a server has successfully been spawned, this is the amount of time
we wait before assuming that the server is unable to accept
connections.
"""
)
).tag(config=True)
poll_interval = Integer(30, config=True,
poll_interval = Integer(30,
help="""Interval (in seconds) on which to poll the spawner."""
)
).tag(config=True)
_callbacks = List()
_poll_callback = Any()
debug = Bool(False, config=True,
debug = Bool(False,
help="Enable debug-logging of the single-user server"
)
).tag(config=True)
options_form = Unicode("", help="""
An HTML form for options a user can specify on launching their server.
The surrounding `<form>` element and the submit button are already provided.
For example:
Set your key:
<input name="key" val="default_key"></input>
<br>
Choose a letter:
<select name="letter" multiple="true">
<option value="A">The letter A</option>
<option value="B">The letter B</option>
</select>
""").tag(config=True)
def options_from_form(self, form_data):
"""Interpret HTTP form data
Form data will always arrive as a dict of lists of strings.
Override this function to understand single-values, numbers, etc.
This should coerce form data into the structure expected by self.user_options,
which must be a dict.
Instances will receive this data on self.user_options, after passing through this function,
prior to `Spawner.start`.
"""
return form_data
user_options = Dict(help="This is where form-specified options ultimately end up.")
env_keep = List([
'PATH',
@@ -83,31 +113,58 @@ class Spawner(LoggingConfigurable):
'VIRTUAL_ENV',
'LANG',
'LC_ALL',
], config=True,
],
help="Whitelist of environment variables for the subprocess to inherit"
)
env = Dict()
def _env_default(self):
env = {}
for key in self.env_keep:
if key in os.environ:
env[key] = os.environ[key]
env['JPY_API_TOKEN'] = self.api_token
return env
).tag(config=True)
env = Dict(help="""Deprecated: use Spawner.get_env or Spawner.environment
cmd = Command(['jupyterhub-singleuser'], config=True,
- extend Spawner.get_env for adding required env in Spawner subclasses
- Spawner.environment for config-specified env
""")
environment = Dict(
help="""Environment variables to load for the Spawner.
Value could be a string or a callable. If it is a callable, it will
be called with one parameter, which will be the instance of the spawner
in use. It should quickly (without doing much blocking operations) return
a string that will be used as the value for the environment variable.
"""
).tag(config=True)
cmd = Command(['jupyterhub-singleuser'],
help="""The command used for starting notebooks."""
)
args = List(Unicode, config=True,
).tag(config=True)
args = List(Unicode(),
help="""Extra arguments to be passed to the single-user server"""
)
).tag(config=True)
notebook_dir = Unicode('', config=True,
notebook_dir = Unicode('',
help="""The notebook directory for the single-user server
`~` will be expanded to the user's home directory
`%U` will be expanded to the user's username
"""
)
).tag(config=True)
default_url = Unicode('',
help="""The default URL for the single-user server.
Can be used in conjunction with --notebook-dir=/ to enable
full filesystem traversal, while preserving user's homedir as
landing page for notebook
`%U` will be expanded to the user's username
"""
).tag(config=True)
disable_user_config = Bool(False,
help="""Disable per-user configuration of single-user servers.
This prevents any config in users' $HOME directories
from having an effect on their server.
"""
).tag(config=True)
def __init__(self, **kwargs):
super(Spawner, self).__init__(**kwargs)
@@ -133,7 +190,7 @@ class Spawner(LoggingConfigurable):
"""store the state necessary for load_state
A black box of extra state for custom spawners.
Should call `super`.
Subclasses should call `super`.
Returns
-------
@@ -153,6 +210,36 @@ class Spawner(LoggingConfigurable):
"""
self.api_token = ''
def get_env(self):
"""Return the environment dict to use for the Spawner.
This applies things like `env_keep`, anything defined in `Spawner.environment`,
and adds the API token to the env.
Use this to access the env in Spawner.start to allow extension in subclasses.
"""
env = {}
if self.env:
warnings.warn("Spawner.env is deprecated, found %s" % self.env, DeprecationWarning)
env.update(self.env)
for key in self.env_keep:
if key in os.environ:
env[key] = os.environ[key]
# config overrides. If the value is a callable, it will be called with
# one parameter - the current spawner instance - and the return value
# will be assigned to the environment variable. This will be called at
# spawn time.
for key, value in self.environment.items():
if callable(value):
env[key] = value(self)
else:
env[key] = value
env['JPY_API_TOKEN'] = self.api_token
return env
def get_args(self):
"""Return the arguments to be passed after self.cmd"""
args = [
@@ -160,15 +247,23 @@ class Spawner(LoggingConfigurable):
'--port=%i' % self.user.server.port,
'--cookie-name=%s' % self.user.server.cookie_name,
'--base-url=%s' % self.user.server.base_url,
'--hub-host=%s' % self.hub.host,
'--hub-prefix=%s' % self.hub.server.base_url,
'--hub-api-url=%s' % self.hub.api_url,
]
if self.ip:
args.append('--ip=%s' % self.ip)
if self.notebook_dir:
self.notebook_dir = self.notebook_dir.replace("%U",self.user.name)
args.append('--notebook-dir=%s' % self.notebook_dir)
if self.default_url:
self.default_url = self.default_url.replace("%U",self.user.name)
args.append('--NotebookApp.default_url=%s' % self.default_url)
if self.debug:
args.append('--debug')
if self.disable_user_config:
args.append('--disable-user-config')
args.extend(self.args)
return args
@@ -240,21 +335,23 @@ class Spawner(LoggingConfigurable):
self.stop_polling()
add_callback = IOLoop.current().add_callback
for callback in self._callbacks:
add_callback(callback)
try:
yield gen.maybe_future(callback())
except Exception:
self.log.exception("Unhandled error in poll callback for %s", self)
return status
death_interval = Float(0.1)
@gen.coroutine
def wait_for_death(self, timeout=10):
"""wait for the process to die, up to timeout seconds"""
loop = IOLoop.current()
for i in range(int(timeout / self.death_interval)):
status = yield self.poll()
if status is not None:
break
else:
yield gen.Task(loop.add_timeout, loop.time() + self.death_interval)
yield gen.sleep(self.death_interval)
def _try_setcwd(path):
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
@@ -262,12 +359,13 @@ def _try_setcwd(path):
try:
os.chdir(path)
except OSError as e:
exc = e # break exception instance out of except scope
print("Couldn't set CWD to %s (%s)" % (path, e), file=sys.stderr)
path, _ = os.path.split(path)
else:
return
print("Couldn't set CWD at all (%s), using temp dir" % e, file=sys.stderr)
td = TemporaryDirectory().name
print("Couldn't set CWD at all (%s), using temp dir" % exc, file=sys.stderr)
td = mkdtemp()
os.chdir(td)
@@ -280,9 +378,6 @@ def set_user_setuid(username):
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
def preexec():
# don't forward signals
os.setpgrp()
# set the user and group
os.setgid(gid)
try:
@@ -298,17 +393,22 @@ def set_user_setuid(username):
class LocalProcessSpawner(Spawner):
"""A Spawner that just uses Popen to start local processes."""
"""A Spawner that just uses Popen to start local processes as users.
INTERRUPT_TIMEOUT = Integer(10, config=True,
Requires users to exist on the local system.
This is the default spawner for JupyterHub.
"""
INTERRUPT_TIMEOUT = Integer(10,
help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM"
)
TERM_TIMEOUT = Integer(5, config=True,
).tag(config=True)
TERM_TIMEOUT = Integer(5,
help="Seconds to wait for process to halt after SIGTERM before proceeding to SIGKILL"
)
KILL_TIMEOUT = Integer(5, config=True,
).tag(config=True)
KILL_TIMEOUT = Integer(5,
help="Seconds to wait for process to halt after SIGKILL before giving up"
)
).tag(config=True)
proc = Instance(Popen, allow_none=True)
pid = Integer(0)
@@ -346,9 +446,11 @@ class LocalProcessSpawner(Spawner):
env['SHELL'] = shell
return env
def _env_default(self):
env = super()._env_default()
return self.user_env(env)
def get_env(self):
"""Add user environment variables"""
env = super().get_env()
env = self.user_env(env)
return env
@gen.coroutine
def start(self):
@@ -357,7 +459,7 @@ class LocalProcessSpawner(Spawner):
self.user.server.ip = self.ip
self.user.server.port = random_port()
cmd = []
env = self.env.copy()
env = self.get_env()
cmd.extend(self.cmd)
cmd.extend(self.get_args())
@@ -365,6 +467,7 @@ class LocalProcessSpawner(Spawner):
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
self.proc = Popen(cmd, env=env,
preexec_fn=self.make_preexec_fn(self.user.name),
start_new_session=True, # don't forward signals
)
self.pid = self.proc.pid
@@ -441,5 +544,5 @@ class LocalProcessSpawner(Spawner):
status = yield self.poll()
if status is None:
# it all failed, zombie process
self.log.warn("Process %i never died", self.pid)
self.log.warning("Process %i never died", self.pid)

View File

@@ -1,7 +1,7 @@
"""mock utilities for testing"""
import os
import sys
from datetime import timedelta
from tempfile import NamedTemporaryFile
import threading
@@ -13,21 +13,26 @@ from tornado import gen
from tornado.concurrent import Future
from tornado.ioloop import IOLoop
from ..spawner import LocalProcessSpawner
from traitlets import default
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from .. import orm
from ..spawner import LocalProcessSpawner
from ..utils import url_path_join
from pamela import PAMError
def mock_authenticate(username, password, service='login'):
# mimic simplepam's failure to handle unicode
if isinstance(username, str):
return False
if isinstance(password, str):
return False
# just use equality for testing
if password == username:
return True
else:
raise PAMError("Fake")
def mock_open_session(username, service):
pass
class MockSpawner(LocalProcessSpawner):
@@ -41,7 +46,7 @@ class MockSpawner(LocalProcessSpawner):
def user_env(self, env):
return env
@default('cmd')
def _cmd_default(self):
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
@@ -51,18 +56,19 @@ class SlowSpawner(MockSpawner):
@gen.coroutine
def start(self):
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
yield super().start()
yield gen.sleep(2)
@gen.coroutine
def stop(self):
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
yield gen.sleep(2)
yield super().stop()
class NeverSpawner(MockSpawner):
"""A spawner that will never start"""
@default('start_timeout')
def _start_timeout_default(self):
return 1
@@ -71,7 +77,23 @@ class NeverSpawner(MockSpawner):
return Future()
class FormSpawner(MockSpawner):
options_form = "IMAFORM"
def options_from_form(self, form_data):
options = {}
options['notspecified'] = 5
if 'bounds' in form_data:
options['bounds'] = [int(i) for i in form_data['bounds']]
if 'energy' in form_data:
options['energy'] = form_data['energy'][0]
if 'hello_file' in form_data:
options['hello'] = form_data['hello_file'][0]
return options
class MockPAMAuthenticator(PAMAuthenticator):
@default('admin_users')
def _admin_users_default(self):
return {'admin'}
@@ -80,20 +102,34 @@ class MockPAMAuthenticator(PAMAuthenticator):
return not user.name.startswith('dne')
def authenticate(self, *args, **kwargs):
with mock.patch('simplepam.authenticate', mock_authenticate):
with mock.patch.multiple('pamela',
authenticate=mock_authenticate,
open_session=mock_open_session,
close_session=mock_open_session,
):
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
class MockHub(JupyterHub):
"""Hub with various mock bits"""
db_file = None
confirm_no_ssl = True
last_activity_interval = 2
@default('subdomain_host')
def _subdomain_host_default(self):
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
@default('ip')
def _ip_default(self):
return 'localhost'
return '127.0.0.1'
@default('authenticator_class')
def _authenticator_class_default(self):
return MockPAMAuthenticator
@default('spawner_class')
def _spawner_class_default(self):
return MockSpawner
@@ -102,7 +138,8 @@ class MockHub(JupyterHub):
def start(self, argv=None):
self.db_file = NamedTemporaryFile()
self.db_url = 'sqlite:///' + self.db_file.name
self.pid_file = NamedTemporaryFile(delete=False).name
self.db_url = self.db_file.name
evt = threading.Event()
@@ -139,13 +176,33 @@ class MockHub(JupyterHub):
self.db_file.close()
def login_user(self, name):
r = requests.post(self.proxy.public_server.url + 'hub/login',
base_url = public_url(self)
r = requests.post(base_url + 'hub/login',
data={
'username': name,
'password': name,
},
allow_redirects=False,
)
r.raise_for_status()
assert r.cookies
return r.cookies
def public_host(app):
if app.subdomain_host:
return app.subdomain_host
else:
return app.proxy.public_server.host
def public_url(app):
return public_host(app) + app.proxy.public_server.base_url
def user_url(user, app):
if app.subdomain_host:
host = user.host
else:
host = public_host(app)
return host + user.server.base_url

View File

@@ -2,17 +2,18 @@
import json
import time
from datetime import timedelta
from queue import Queue
from urllib.parse import urlparse
from urllib.parse import urlparse, quote
import requests
from tornado import gen
from ..utils import url_path_join as ujoin
from .. import orm
from ..user import User
from ..utils import url_path_join as ujoin
from . import mocking
from .mocking import public_url, user_url
def check_db_locks(func):
@@ -41,11 +42,15 @@ def check_db_locks(func):
def find_user(db, name):
return db.query(orm.User).filter(orm.User.name==name).first()
def add_user(db, **kwargs):
user = orm.User(**kwargs)
db.add(user)
def add_user(db, app=None, **kwargs):
orm_user = orm.User(**kwargs)
db.add(orm_user)
db.commit()
if app:
user = app.users[orm_user.id] = User(orm_user, app.tornado_settings)
return user
else:
return orm_user
def auth_header(db, name):
user = find_user(db, name)
@@ -100,7 +105,7 @@ def test_auth_api(app):
def test_referer_check(app, io_loop):
url = app.hub.server.url
url = ujoin(public_url(app), app.hub.server.base_url)
host = urlparse(url).netloc
user = find_user(app.db, 'admin')
if user is None:
@@ -204,6 +209,17 @@ def test_add_multi_user_bad(app):
r = api_request(app, 'users', method='post', data='[]')
assert r.status_code == 400
def test_add_multi_user_invalid(app):
app.authenticator.username_pattern = r'w.*'
r = api_request(app, 'users', method='post',
data=json.dumps({'usernames': ['Willow', 'Andrew', 'Tara']})
)
app.authenticator.username_pattern = ''
assert r.status_code == 400
assert r.json()['message'] == 'Invalid usernames: andrew, tara'
def test_add_multi_user(app):
db = app.db
names = ['a', 'b']
@@ -310,35 +326,45 @@ def get_app_user(app, name):
No ORM methods should be called on the result.
"""
q = Queue()
def get_user():
def get_user_id():
user = find_user(app.db, name)
q.put(user)
app.io_loop.add_callback(get_user)
return q.get(timeout=2)
q.put(user.id)
app.io_loop.add_callback(get_user_id)
user_id = q.get(timeout=2)
return app.users[user_id]
def test_spawn(app, io_loop):
db = app.db
name = 'wash'
user = add_user(db, name=name)
r = api_request(app, 'users', name, 'server', method='post')
user = add_user(db, app=app, name=name)
options = {
's': ['value'],
'i': 5,
}
r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options))
assert r.status_code == 201
assert 'pid' in user.state
app_user = get_app_user(app, name)
assert app_user.spawner is not None
assert app_user.spawner.user_options == options
assert not app_user.spawn_pending
status = io_loop.run_sync(app_user.spawner.poll)
assert status is None
assert user.server.base_url == '/user/%s' % name
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
url = user_url(user, app)
print(url)
r = requests.get(url)
assert r.status_code == 200
assert r.text == user.server.base_url
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
r = requests.get(ujoin(url, 'args'))
assert r.status_code == 200
argv = r.json()
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
assert expected in argv
if app.subdomain_host:
assert '--hub-host=%s' % app.subdomain_host in argv
r = api_request(app, 'users', name, 'server', method='delete')
assert r.status_code == 204
@@ -348,14 +374,16 @@ def test_spawn(app, io_loop):
assert status == 0
def test_slow_spawn(app, io_loop):
app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
# app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner
app.tornado_settings['spawner_class'] = mocking.SlowSpawner
app.tornado_application.settings['slow_spawn_timeout'] = 0
app.tornado_application.settings['slow_stop_timeout'] = 0
db = app.db
name = 'zoe'
user = add_user(db, name=name)
user = add_user(db, app=app, name=name)
r = api_request(app, 'users', name, 'server', method='post')
app.tornado_settings['spawner_class'] = mocking.MockSpawner
r.raise_for_status()
assert r.status_code == 202
app_user = get_app_user(app, name)
@@ -363,11 +391,10 @@ def test_slow_spawn(app, io_loop):
assert app_user.spawn_pending
assert not app_user.stop_pending
dt = timedelta(seconds=0.1)
@gen.coroutine
def wait_spawn():
while app_user.spawn_pending:
yield gen.Task(io_loop.add_timeout, dt)
yield gen.sleep(0.1)
io_loop.run_sync(wait_spawn)
assert not app_user.spawn_pending
@@ -377,7 +404,7 @@ def test_slow_spawn(app, io_loop):
@gen.coroutine
def wait_stop():
while app_user.stop_pending:
yield gen.Task(io_loop.add_timeout, dt)
yield gen.sleep(0.1)
r = api_request(app, 'users', name, 'server', method='delete')
r.raise_for_status()
@@ -399,22 +426,22 @@ def test_slow_spawn(app, io_loop):
def test_never_spawn(app, io_loop):
app.tornado_application.settings['spawner_class'] = mocking.NeverSpawner
app.tornado_settings['spawner_class'] = mocking.NeverSpawner
app.tornado_application.settings['slow_spawn_timeout'] = 0
db = app.db
name = 'badger'
user = add_user(db, name=name)
user = add_user(db, app=app, name=name)
r = api_request(app, 'users', name, 'server', method='post')
app.tornado_settings['spawner_class'] = mocking.MockSpawner
app_user = get_app_user(app, name)
assert app_user.spawner is not None
assert app_user.spawn_pending
dt = timedelta(seconds=0.1)
@gen.coroutine
def wait_pending():
while app_user.spawn_pending:
yield gen.Task(io_loop.add_timeout, dt)
yield gen.sleep(0.1)
io_loop.run_sync(wait_pending)
assert not app_user.spawn_pending
@@ -429,6 +456,76 @@ def test_get_proxy(app, io_loop):
assert list(reply.keys()) == ['/']
def test_cookie(app):
db = app.db
name = 'patience'
user = add_user(db, app=app, name=name)
r = api_request(app, 'users', name, 'server', method='post')
assert r.status_code == 201
assert 'pid' in user.state
app_user = get_app_user(app, name)
cookies = app.login_user(name)
# cookie jar gives '"cookie-value"', we want 'cookie-value'
cookie = cookies[user.server.cookie_name][1:-1]
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, "nothintoseehere")
assert r.status_code == 404
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, quote(cookie, safe=''))
r.raise_for_status()
reply = r.json()
assert reply['name'] == name
# deprecated cookie in body:
r = api_request(app, 'authorizations/cookie', user.server.cookie_name, data=cookie)
r.raise_for_status()
reply = r.json()
assert reply['name'] == name
def test_token(app):
name = 'book'
user = add_user(app.db, app=app, name=name)
token = user.new_api_token()
r = api_request(app, 'authorizations/token', token)
r.raise_for_status()
user_model = r.json()
assert user_model['name'] == name
r = api_request(app, 'authorizations/token', 'notauthorized')
assert r.status_code == 404
def test_get_token(app):
name = 'user'
user = add_user(app.db, app=app, name=name)
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
'username': name,
'password': name,
}))
assert r.status_code == 200
data = r.content.decode("utf-8")
token = json.loads(data)
assert not token['Authentication'] is None
def test_bad_get_token(app):
name = 'user'
password = 'fake'
user = add_user(app.db, app=app, name=name)
r = api_request(app, 'authorizations/token', method='post', data=json.dumps({
'username': name,
'password': password,
}))
assert r.status_code == 403
def test_options(app):
r = api_request(app, 'users', method='options')
r.raise_for_status()
assert 'Access-Control-Allow-Headers' in r.headers
def test_bad_json_body(app):
r = api_request(app, 'users', method='post', data='notjson')
assert r.status_code == 400
def test_shutdown(app):
r = api_request(app, 'shutdown', method='post', data=json.dumps({
'servers': True,

View File

@@ -1,11 +1,17 @@
"""Test the JupyterHub entry point"""
import binascii
import os
import re
import sys
from getpass import getuser
from subprocess import check_output
from subprocess import check_output, Popen, PIPE
from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch
import pytest
from .mocking import MockHub
from .. import orm
def test_help_all():
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
@@ -16,16 +22,31 @@ def test_token_app():
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
with TemporaryDirectory() as td:
out = check_output(cmd + [getuser()], cwd=td).decode('utf8', 'replace').strip()
with open(os.path.join(td, 'jupyterhub_config.py'), 'w') as f:
f.write("c.Authenticator.admin_users={'user'}")
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
assert re.match(r'^[a-z0-9]+$', out)
def test_generate_config():
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
cfg_file = tf.name
with open(cfg_file, 'w') as f:
f.write("c.A = 5")
p = Popen([sys.executable, '-m', 'jupyterhub',
'--generate-config', '-f', cfg_file],
stdout=PIPE, stdin=PIPE)
out, _ = p.communicate(b'n')
out = out.decode('utf8', 'replace')
assert os.path.exists(cfg_file)
with open(cfg_file) as f:
cfg_text = f.read()
assert cfg_text == 'c.A = 5'
out = check_output([sys.executable, '-m', 'jupyterhub',
'--generate-config', '-f', cfg_file]
).decode('utf8', 'replace')
p = Popen([sys.executable, '-m', 'jupyterhub',
'--generate-config', '-f', cfg_file],
stdout=PIPE, stdin=PIPE)
out, _ = p.communicate(b'x\ny')
out = out.decode('utf8', 'replace')
assert os.path.exists(cfg_file)
with open(cfg_file) as f:
cfg_text = f.read()
@@ -33,3 +54,89 @@ def test_generate_config():
assert cfg_file in out
assert 'Spawner.cmd' in cfg_text
assert 'Authenticator.whitelist' in cfg_text
def test_init_tokens():
with TemporaryDirectory() as td:
db_file = os.path.join(td, 'jupyterhub.sqlite')
tokens = {
'super-secret-token': 'alyx',
'also-super-secret': 'gordon',
'boagasdfasdf': 'chell',
}
app = MockHub(db_file=db_file, api_tokens=tokens)
app.initialize([])
db = app.db
for token, username in tokens.items():
api_token = orm.APIToken.find(db, token)
assert api_token is not None
user = api_token.user
assert user.name == username
# simulate second startup, reloading same tokens:
app = MockHub(db_file=db_file, api_tokens=tokens)
app.initialize([])
db = app.db
for token, username in tokens.items():
api_token = orm.APIToken.find(db, token)
assert api_token is not None
user = api_token.user
assert user.name == username
# don't allow failed token insertion to create users:
tokens['short'] = 'gman'
app = MockHub(db_file=db_file, api_tokens=tokens)
# with pytest.raises(ValueError):
app.initialize([])
assert orm.User.find(app.db, 'gman') is None
def test_write_cookie_secret(tmpdir):
secret_path = str(tmpdir.join('cookie_secret'))
hub = MockHub(cookie_secret_file=secret_path)
hub.init_secrets()
assert os.path.exists(secret_path)
assert os.stat(secret_path).st_mode & 0o600
assert not os.stat(secret_path).st_mode & 0o177
def test_cookie_secret_permissions(tmpdir):
secret_file = tmpdir.join('cookie_secret')
secret_path = str(secret_file)
secret = os.urandom(1024)
secret_file.write(binascii.b2a_base64(secret))
hub = MockHub(cookie_secret_file=secret_path)
# raise with public secret file
os.chmod(secret_path, 0o664)
with pytest.raises(SystemExit):
hub.init_secrets()
# ok with same file, proper permissions
os.chmod(secret_path, 0o660)
hub.init_secrets()
assert hub.cookie_secret == secret
def test_cookie_secret_content(tmpdir):
secret_file = tmpdir.join('cookie_secret')
secret_file.write('not base 64: uñiço∂e')
secret_path = str(secret_file)
os.chmod(secret_path, 0o660)
hub = MockHub(cookie_secret_file=secret_path)
with pytest.raises(SystemExit):
hub.init_secrets()
def test_cookie_secret_env(tmpdir):
hub = MockHub(cookie_secret_file=str(tmpdir.join('cookie_secret')))
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'not hex'}):
with pytest.raises(ValueError):
hub.init_secrets()
with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'abc123'}):
hub.init_secrets()
assert hub.cookie_secret == binascii.a2b_hex('abc123')
assert not os.path.exists(hub.cookie_secret_file)

View File

@@ -3,7 +3,6 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from subprocess import CalledProcessError
from unittest import mock
import pytest
@@ -13,13 +12,13 @@ from jupyterhub import auth, orm
def test_pam_auth(io_loop):
authenticator = MockPAMAuthenticator()
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'match',
'password': 'match',
}))
assert authorized == 'match'
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'match',
'password': 'nomatch',
}))
@@ -27,19 +26,19 @@ def test_pam_auth(io_loop):
def test_pam_auth_whitelist(io_loop):
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'kaylee',
'password': 'kaylee',
}))
assert authorized == 'kaylee'
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'wash',
'password': 'nomatch',
}))
assert authorized is None
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'mal',
'password': 'mal',
}))
@@ -59,14 +58,14 @@ def test_pam_auth_group_whitelist(io_loop):
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
with mock.patch.object(auth, 'getgrnam', getgrnam):
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'kaylee',
'password': 'kaylee',
}))
assert authorized == 'kaylee'
with mock.patch.object(auth, 'getgrnam', getgrnam):
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'mal',
'password': 'mal',
}))
@@ -75,7 +74,7 @@ def test_pam_auth_group_whitelist(io_loop):
def test_pam_auth_no_such_group(io_loop):
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
'username': 'kaylee',
'password': 'kaylee',
}))
@@ -93,33 +92,47 @@ def test_wont_add_system_user(io_loop):
def test_cant_add_system_user(io_loop):
user = orm.User(name='lioness4321')
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
authenticator.add_user_cmd = ['jupyterhub-fake-command']
authenticator.create_system_users = True
def check_output(cmd, *a, **kw):
raise CalledProcessError(1, cmd)
class DummyFile:
def read(self):
return b'dummy error'
with mock.patch.object(auth, 'check_output', check_output):
with pytest.raises(RuntimeError):
class DummyPopen:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.returncode = 1
self.stdout = DummyFile()
def wait(self):
return
with mock.patch.object(auth, 'Popen', DummyPopen):
with pytest.raises(RuntimeError) as exc:
io_loop.run_sync(lambda : authenticator.add_user(user))
assert str(exc.value) == 'Failed to create system user lioness4321: dummy error'
def test_add_system_user(io_loop):
user = orm.User(name='lioness4321')
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
authenticator.create_system_users = True
def check_output(*a, **kw):
return
authenticator.add_user_cmd = ['echo', '/home/USERNAME']
record = {}
def check_call(cmd, *a, **kw):
class DummyPopen:
def __init__(self, cmd, *args, **kwargs):
record['cmd'] = cmd
self.returncode = 0
with mock.patch.object(auth, 'check_output', check_output), \
mock.patch.object(auth, 'check_call', check_call):
def wait(self):
return
with mock.patch.object(auth, 'Popen', DummyPopen):
io_loop.run_sync(lambda : authenticator.add_user(user))
assert user.name in record['cmd']
assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321']
def test_delete_user(io_loop):
@@ -147,3 +160,37 @@ def test_handlers(app):
assert handlers[0][0] == '/login'
def test_normalize_names(io_loop):
a = MockPAMAuthenticator()
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
'username': 'ZOE',
'password': 'ZOE',
}))
assert authorized == 'zoe'
def test_username_map(io_loop):
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
'username': 'WASH',
'password': 'WASH',
}))
assert authorized == 'alpha'
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
'username': 'Inara',
'password': 'Inara',
}))
assert authorized == 'inara'
def test_validate_names(io_loop):
a = auth.PAMAuthenticator()
assert a.validate_username('willow')
assert a.validate_username('giles')
a = auth.PAMAuthenticator(username_pattern='w.*')
assert not a.validate_username('xander')
assert a.validate_username('willow')

View File

@@ -7,6 +7,7 @@ import pytest
from tornado import gen
from .. import orm
from ..user import User
from .mocking import MockSpawner
@@ -19,7 +20,7 @@ def test_server(db):
assert server.proto == 'http'
assert isinstance(server.port, int)
assert isinstance(server.cookie_name, str)
assert server.host == 'http://localhost:%i' % server.port
assert server.host == 'http://127.0.0.1:%i' % server.port
assert server.url == server.host + '/'
assert server.bind_url == 'http://*:%i/' % server.port
server.ip = '127.0.0.1'
@@ -92,10 +93,20 @@ def test_tokens(db):
found = orm.APIToken.find(db, 'something else')
assert found is None
secret = 'super-secret-preload-token'
token = user.new_api_token(secret)
assert token == secret
assert len(user.api_tokens) == 3
# raise ValueError on collision
with pytest.raises(ValueError):
user.new_api_token(token)
assert len(user.api_tokens) == 3
def test_spawn_fails(db, io_loop):
user = orm.User(name='aeofel')
db.add(user)
orm_user = orm.User(name='aeofel')
db.add(orm_user)
db.commit()
class BadSpawner(MockSpawner):
@@ -103,8 +114,13 @@ def test_spawn_fails(db, io_loop):
def start(self):
raise RuntimeError("Split the party")
user = User(orm_user, {
'spawner_class': BadSpawner,
'config': None,
})
with pytest.raises(Exception) as exc:
io_loop.run_sync(lambda : user.spawn(BadSpawner))
io_loop.run_sync(user.spawn)
assert user.server is None
assert not user.running

View File

@@ -1,13 +1,18 @@
"""Tests for HTML pages"""
from urllib.parse import urlencode, urlparse
import requests
from ..utils import url_path_join as ujoin
from .. import orm
import mock
from .mocking import FormSpawner, public_url, public_host, user_url
from .test_api import api_request
def get_page(path, app, **kw):
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
base_url = ujoin(public_url(app), app.hub.server.base_url)
print(base_url)
return requests.get(ujoin(base_url, path), **kw)
@@ -16,15 +21,17 @@ def test_root_no_auth(app, io_loop):
routes = io_loop.run_sync(app.proxy.get_routes)
print(routes)
print(app.hub.server)
r = requests.get(app.proxy.public_server.host)
url = public_url(app)
print(url)
r = requests.get(url)
r.raise_for_status()
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url)
assert r.url == ujoin(url, app.hub.server.base_url, 'login')
def test_root_auth(app):
cookies = app.login_user('river')
r = requests.get(app.proxy.public_server.host, cookies=cookies)
r = requests.get(public_url(app), cookies=cookies)
r.raise_for_status()
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
assert r.url == user_url(app.users['river'], app)
def test_home_no_auth(app):
r = get_page('home', app, allow_redirects=False)
@@ -56,3 +63,184 @@ def test_admin(app):
r.raise_for_status()
assert r.url.endswith('/admin')
def test_spawn_redirect(app, io_loop):
name = 'wash'
cookies = app.login_user(name)
u = app.users[orm.User.find(app.db, name)]
# ensure wash's server isn't running:
r = api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
r.raise_for_status()
status = io_loop.run_sync(u.spawner.poll)
assert status is not None
# test spawn page when no server is running
r = get_page('spawn', app, cookies=cookies)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == '/user/%s' % name
# should have started server
status = io_loop.run_sync(u.spawner.poll)
assert status is None
# test spawn page when server is already running (just redirect)
r = get_page('spawn', app, cookies=cookies)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == '/user/%s' % name
def test_spawn_page(app):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
cookies = app.login_user('jones')
r = get_page('spawn', app, cookies=cookies)
assert r.url.endswith('/spawn')
assert FormSpawner.options_form in r.text
def test_spawn_form(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_url(app), app.hub.server.base_url)
cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
io_loop.run_sync(u.stop)
r = requests.post(ujoin(base_url, 'spawn'), cookies=cookies, data={
'bounds': ['-1', '1'],
'energy': '511keV',
})
r.raise_for_status()
print(u.spawner)
print(u.spawner.user_options)
assert u.spawner.user_options == {
'energy': '511keV',
'bounds': [-1, 1],
'notspecified': 5,
}
def test_spawn_form_with_file(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_url(app), app.hub.server.base_url)
cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
io_loop.run_sync(u.stop)
r = requests.post(ujoin(base_url, 'spawn'),
cookies=cookies,
data={
'bounds': ['-1', '1'],
'energy': '511keV',
},
files={'hello': ('hello.txt', b'hello world\n')}
)
r.raise_for_status()
print(u.spawner)
print(u.spawner.user_options)
assert u.spawner.user_options == {
'energy': '511keV',
'bounds': [-1, 1],
'notspecified': 5,
'hello': {'filename': 'hello.txt',
'body': b'hello world\n',
'content_type': 'application/unknown'},
}
def test_user_redirect(app):
name = 'wash'
cookies = app.login_user(name)
r = get_page('/user/baduser', app, cookies=cookies)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == '/user/%s' % name
r = get_page('/user/baduser/test.ipynb', app, cookies=cookies)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == '/user/%s/test.ipynb' % name
r = get_page('/user/baduser/test.ipynb', app)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == '/hub/login'
query = urlparse(r.url).query
assert query == urlencode({'next': '/hub/user/baduser/test.ipynb'})
def test_login_fail(app):
name = 'wash'
base_url = public_url(app)
r = requests.post(base_url + 'hub/login',
data={
'username': name,
'password': 'wrong',
},
allow_redirects=False,
)
assert not r.cookies
def test_login_redirect(app, io_loop):
cookies = app.login_user('river')
user = app.users['river']
# no next_url, server running
io_loop.run_sync(user.spawn)
r = get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/user/river' in r.headers['Location']
# no next_url, server not running
io_loop.run_sync(user.stop)
r = get_page('login', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert '/hub/' in r.headers['Location']
# next URL given, use it
r = get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert r.headers['Location'].endswith('/hub/admin')
def test_logout(app):
name = 'wash'
cookies = app.login_user(name)
r = requests.get(public_host(app) + app.tornado_settings['logout_url'], cookies=cookies)
r.raise_for_status()
login_url = public_host(app) + app.tornado_settings['login_url']
assert r.url == login_url
assert r.cookies == {}
def test_login_no_whitelist_adds_user(app):
auth = app.authenticator
mock_add_user = mock.Mock()
with mock.patch.object(auth, 'add_user', mock_add_user):
cookies = app.login_user('jubal')
user = app.users['jubal']
assert mock_add_user.mock_calls == [mock.call(user)]
def test_static_files(app):
base_url = ujoin(public_url(app), app.hub.server.base_url)
print(base_url)
r = requests.get(ujoin(base_url, 'logo'))
r.raise_for_status()
assert r.headers['content-type'] == 'image/png'
r = requests.get(ujoin(base_url, 'static', 'images', 'jupyter.png'))
r.raise_for_status()
assert r.headers['content-type'] == 'image/png'
r = requests.get(ujoin(base_url, 'static', 'css', 'style.min.css'))
r.raise_for_status()
assert r.headers['content-type'] == 'text/css'

View File

@@ -4,6 +4,7 @@ import json
import os
from queue import Queue
from subprocess import Popen
from urllib.parse import urlparse
from .. import orm
from .mocking import MockHub
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
]
if app.subdomain_host:
cmd.append('--host-routing')
proxy = Popen(cmd, env=env)
def _cleanup_proxy():
if proxy.poll() is None:
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
r.raise_for_status()
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
user_path = '/user/river'
if app.subdomain_host:
domain = urlparse(app.subdomain_host).hostname
user_path = '/%s.%s' % (name, domain) + user_path
assert sorted(routes.keys()) == ['/', user_path]
# teardown the proxy and start a new one in the same place
proxy.terminate()
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
assert sorted(routes.keys()) == ['/', user_path]
# teardown the proxy again, and start a new one with different auth and port
proxy.terminate()
@@ -90,13 +97,16 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
]
if app.subdomain_host:
cmd.append('--host-routing')
proxy = Popen(cmd, env=env)
wait_for_proxy()
# tell the hub where the new proxy is
r = api_request(app, 'proxy', method='patch', data=json.dumps({
'port': proxy_port,
'protocol': 'http',
'ip': app.ip,
'auth_token': new_auth_token,
}))
r.raise_for_status()
@@ -113,7 +123,8 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
assert sorted(routes.keys()) == ['/', user_path]
def test_check_routes(app, io_loop):
proxy = app.proxy
@@ -123,13 +134,24 @@ def test_check_routes(app, io_loop):
r.raise_for_status()
zoe = orm.User.find(app.db, 'zoe')
assert zoe is not None
zoe = app.users[zoe]
before = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' in before
io_loop.run_sync(app.proxy.check_routes)
assert zoe.proxy_path in before
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
io_loop.run_sync(lambda : proxy.delete_user(zoe))
during = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' not in during
io_loop.run_sync(app.proxy.check_routes)
assert zoe.proxy_path not in during
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
after = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' in after
assert zoe.proxy_path in after
assert before == after
def test_patch_proxy_bad_req(app):
r = api_request(app, 'proxy', method='patch')
assert r.status_code == 400
r = api_request(app, 'proxy', method='patch', data='notjson')
assert r.status_code == 400
r = api_request(app, 'proxy', method='patch', data=json.dumps([]))
assert r.status_code == 400

View File

@@ -4,9 +4,14 @@
# Distributed under the terms of the Modified BSD License.
import logging
import os
import signal
import sys
import tempfile
import time
from unittest import mock
from tornado import gen
from .. import spawner as spawnermod
from ..spawner import LocalProcessSpawner
@@ -39,13 +44,14 @@ def new_spawner(db, **kwargs):
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
kwargs.setdefault('TERM_TIMEOUT', 1)
kwargs.setdefault('KILL_TIMEOUT', 1)
kwargs.setdefault('poll_interval', 1)
return LocalProcessSpawner(db=db, **kwargs)
def test_spawner(db, io_loop):
spawner = new_spawner(db)
io_loop.run_sync(spawner.start)
assert spawner.user.server.ip == 'localhost'
assert spawner.user.server.ip == '127.0.0.1'
# wait for the process to get to the while True: loop
time.sleep(1)
@@ -57,9 +63,9 @@ def test_spawner(db, io_loop):
assert status == 1
def test_single_user_spawner(db, io_loop):
spawner = new_spawner(db, cmd=[sys.executable, '-m', 'jupyterhub.singleuser'])
spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
io_loop.run_sync(spawner.start)
assert spawner.user.server.ip == 'localhost'
assert spawner.user.server.ip == '127.0.0.1'
# wait for http server to come up,
# checking for early termination every 1s
def wait():
@@ -110,3 +116,53 @@ def test_stop_spawner_stop_now(db, io_loop):
status = io_loop.run_sync(spawner.poll)
assert status == -signal.SIGTERM
def test_spawner_poll(db, io_loop):
first_spawner = new_spawner(db)
user = first_spawner.user
io_loop.run_sync(first_spawner.start)
proc = first_spawner.proc
status = io_loop.run_sync(first_spawner.poll)
assert status is None
user.state = first_spawner.get_state()
assert 'pid' in user.state
# create a new Spawner, loading from state of previous
spawner = new_spawner(db, user=first_spawner.user)
spawner.start_polling()
# wait for the process to get to the while True: loop
io_loop.run_sync(lambda : gen.sleep(1))
status = io_loop.run_sync(spawner.poll)
assert status is None
# kill the process
proc.terminate()
for i in range(10):
if proc.poll() is None:
time.sleep(1)
else:
break
assert proc.poll() is not None
io_loop.run_sync(lambda : gen.sleep(2))
status = io_loop.run_sync(spawner.poll)
assert status is not None
def test_setcwd():
cwd = os.getcwd()
with tempfile.TemporaryDirectory() as td:
td = os.path.realpath(os.path.abspath(td))
spawnermod._try_setcwd(td)
assert os.path.samefile(os.getcwd(), td)
os.chdir(cwd)
chdir = os.chdir
temp_root = os.path.realpath(os.path.abspath(tempfile.gettempdir()))
def raiser(path):
path = os.path.realpath(os.path.abspath(path))
if not path.startswith(temp_root):
raise OSError(path)
chdir(path)
with mock.patch('os.chdir', raiser):
spawnermod._try_setcwd(cwd)
assert os.getcwd().startswith(temp_root)
os.chdir(cwd)

View File

@@ -21,7 +21,7 @@ class Command(List):
kwargs.setdefault('minlen', 1)
if isinstance(default_value, str):
default_value = [default_value]
super().__init__(Unicode, default_value, **kwargs)
super().__init__(Unicode(), default_value, **kwargs)
def validate(self, obj, value):
if isinstance(value, str):

310
jupyterhub/user.py Normal file
View File

@@ -0,0 +1,310 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta
from urllib.parse import quote, urlparse
from tornado import gen
from tornado.log import app_log
from sqlalchemy import inspect
from .utils import url_path_join
from . import orm
from traitlets import HasTraits, Any, Dict, observe, default
from .spawner import LocalProcessSpawner
class UserDict(dict):
"""Like defaultdict, but for users
Getting by a user id OR an orm.User instance returns a User wrapper around the orm user.
"""
def __init__(self, db_factory, settings):
self.db_factory = db_factory
self.settings = settings
super().__init__()
@property
def db(self):
return self.db_factory()
def __contains__(self, key):
if isinstance(key, (User, orm.User)):
key = key.id
return dict.__contains__(self, key)
def __getitem__(self, key):
if isinstance(key, User):
key = key.id
elif isinstance(key, str):
orm_user = self.db.query(orm.User).filter(orm.User.name==key).first()
if orm_user is None:
raise KeyError("No such user: %s" % key)
else:
key = orm_user
if isinstance(key, orm.User):
# users[orm_user] returns User(orm_user)
orm_user = key
if orm_user.id not in self:
user = self[orm_user.id] = User(orm_user, self.settings)
return user
user = dict.__getitem__(self, orm_user.id)
user.db = self.db
return user
elif isinstance(key, int):
id = key
if id not in self:
orm_user = self.db.query(orm.User).filter(orm.User.id==id).first()
if orm_user is None:
raise KeyError("No such user: %s" % id)
user = self[id] = User(orm_user, self.settings)
return dict.__getitem__(self, id)
else:
raise KeyError(repr(key))
def __delitem__(self, key):
user = self[key]
user_id = user.id
db = self.db
db.delete(user.orm_user)
db.commit()
dict.__delitem__(self, user_id)
class User(HasTraits):
@default('log')
def _log_default(self):
return app_log
settings = Dict()
db = Any(allow_none=True)
@default('db')
def _db_default(self):
if self.orm_user:
return inspect(self.orm_user).session
@observe('db')
def _db_changed(self, change):
"""Changing db session reacquires ORM User object"""
# db session changed, re-get orm User
if self.orm_user:
id = self.orm_user.id
self.orm_user = change['new'].query(orm.User).filter(orm.User.id==id).first()
self.spawner.db = self.db
orm_user = None
spawner = None
spawn_pending = False
stop_pending = False
@property
def authenticator(self):
return self.settings.get('authenticator', None)
@property
def spawner_class(self):
return self.settings.get('spawner_class', LocalProcessSpawner)
def __init__(self, orm_user, settings, **kwargs):
self.orm_user = orm_user
self.settings = settings
super().__init__(**kwargs)
hub = self.db.query(orm.Hub).first()
self.cookie_name = '%s-%s' % (hub.server.cookie_name, quote(self.name, safe=''))
self.base_url = url_path_join(
self.settings.get('base_url', '/'), 'user', self.escaped_name)
self.spawner = self.spawner_class(
user=self,
db=self.db,
hub=hub,
authenticator=self.authenticator,
config=self.settings.get('config'),
)
# pass get/setattr to ORM user
def __getattr__(self, attr):
if hasattr(self.orm_user, attr):
return getattr(self.orm_user, attr)
else:
raise AttributeError(attr)
def __setattr__(self, attr, value):
if self.orm_user and hasattr(self.orm_user, attr):
setattr(self.orm_user, attr, value)
else:
super().__setattr__(attr, value)
def __repr__(self):
return repr(self.orm_user)
@property
def running(self):
"""property for whether a user has a running server"""
if self.spawn_pending or self.stop_pending:
return False # server is not running if spawn or stop is still pending
if self.server is None:
return False
return True
@property
def escaped_name(self):
"""My name, escaped for use in URLs, cookies, etc."""
return quote(self.name, safe='@')
@property
def proxy_path(self):
if self.settings.get('subdomain_host'):
return url_path_join('/' + self.domain, self.server.base_url)
else:
return self.server.base_url
@property
def domain(self):
"""Get the domain for my server."""
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
return self.escaped_name + '.' + self.settings['domain']
@property
def host(self):
"""Get the *host* for my server (domain[:port])"""
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
parsed = urlparse(self.settings['subdomain_host'])
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
return h
@property
def url(self):
"""My URL
Full name.domain/path if using subdomains, otherwise just my /base/url
"""
if self.settings.get('subdomain_host'):
return '{host}{path}'.format(
host=self.host,
path=self.base_url,
)
else:
return self.base_url
@gen.coroutine
def spawn(self, options=None):
"""Start the user's spawner"""
db = self.db
self.server = orm.Server(
cookie_name=self.cookie_name,
base_url=self.base_url,
)
db.add(self.server)
db.commit()
api_token = self.new_api_token()
db.commit()
spawner = self.spawner
spawner.user_options = options or {}
# we are starting a new server, make sure it doesn't restore state
spawner.clear_state()
spawner.api_token = api_token
# trigger pre-spawn hook on authenticator
authenticator = self.authenticator
if (authenticator):
yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner))
self.spawn_pending = True
# wait for spawner.start to return
try:
f = spawner.start()
# commit any changes in spawner.start (always commit db changes before yield)
db.commit()
yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
except Exception as e:
if isinstance(e, gen.TimeoutError):
self.log.warning("{user}'s server failed to start in {s} seconds, giving up".format(
user=self.name, s=spawner.start_timeout,
))
e.reason = 'timeout'
else:
self.log.error("Unhandled error starting {user}'s server: {error}".format(
user=self.name, error=e,
))
e.reason = 'error'
try:
yield self.stop()
except Exception:
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
user=self.name,
), exc_info=True)
# raise original exception
raise e
spawner.start_polling()
# store state
self.state = spawner.get_state()
self.last_activity = datetime.utcnow()
db.commit()
self.spawn_pending = False
try:
yield self.server.wait_up(http=True, timeout=spawner.http_timeout)
except Exception as e:
if isinstance(e, TimeoutError):
self.log.warning(
"{user}'s server never showed up at {url} "
"after {http_timeout} seconds. Giving up".format(
user=self.name,
url=self.server.url,
http_timeout=spawner.http_timeout,
)
)
e.reason = 'timeout'
else:
e.reason = 'error'
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=self.server.url, error=e,
))
try:
yield self.stop()
except Exception:
self.log.error("Failed to cleanup {user}'s server that failed to start".format(
user=self.name,
), exc_info=True)
# raise original TimeoutError
raise e
return self
@gen.coroutine
def stop(self):
"""Stop the user's spawner
and cleanup after it.
"""
self.spawn_pending = False
spawner = self.spawner
self.spawner.stop_polling()
self.stop_pending = True
try:
status = yield spawner.poll()
if status is None:
yield self.spawner.stop()
spawner.clear_state()
self.state = spawner.get_state()
self.last_activity = datetime.utcnow()
self.server = None
self.db.commit()
finally:
self.stop_pending = False
# trigger post-spawner hook on authenticator
auth = spawner.authenticator
if auth:
yield gen.maybe_future(
auth.post_spawn_stop(self, spawner)
)

View File

@@ -6,10 +6,12 @@
from binascii import b2a_hex
import errno
import hashlib
from hmac import compare_digest
import os
import socket
from threading import Thread
import uuid
from hmac import compare_digest
import warnings
from tornado import web, gen, ioloop
from tornado.httpclient import AsyncHTTPClient, HTTPError
@@ -28,22 +30,32 @@ def random_port():
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
def can_connect(ip, port):
"""Check if we can connect to an ip:port
return True if we can connect, False otherwise.
"""
try:
socket.create_connection((ip, port))
except socket.error as e:
if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}:
app_log.error("Unexpected error connecting to %s:%i %s",
ip, port, e
)
return False
else:
return True
@gen.coroutine
def wait_for_server(ip, port, timeout=10):
"""wait for any server to show up at ip:port"""
loop = ioloop.IOLoop.current()
tic = loop.time()
while loop.time() - tic < timeout:
try:
socket.create_connection((ip, port))
except socket.error as e:
if e.errno != errno.ECONNREFUSED:
app_log.error("Unexpected error waiting for %s:%i %s",
ip, port, e
)
yield gen.Task(loop.add_timeout, loop.time() + 0.1)
else:
if can_connect(ip, port):
return
else:
yield gen.sleep(0.1)
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
**locals()
))
@@ -66,15 +78,15 @@ def wait_for_http_server(url, timeout=10):
if e.code != 599:
# we expect 599 for no connection,
# but 502 or other proxy error is conceivable
app_log.warn("Server at %s responded with error: %s", url, e.code)
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
app_log.warning("Server at %s responded with error: %s", url, e.code)
yield gen.sleep(0.1)
else:
app_log.debug("Server at %s responded with %s", url, e.code)
return
except (OSError, socket.error) as e:
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
app_log.warn("Failed to connect to %s (%s)", url, e)
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
app_log.warning("Failed to connect to %s (%s)", url, e)
yield gen.sleep(0.1)
else:
return
@@ -192,3 +204,4 @@ def url_path_join(*pieces):
result = '/'
return result

View File

@@ -5,8 +5,9 @@
version_info = (
0,
2,
0,
6,
1,
# 'dev',
)
__version__ = '.'.join(map(str, version_info))

11
onbuild/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# JupyterHub Dockerfile that loads your jupyterhub_config.py
#
# Adds ONBUILD step to jupyter/jupyterhub to load your juptyerhub_config.py into the image
#
# Derivative images must have jupyterhub_config.py next to the Dockerfile.
FROM jupyterhub/jupyterhub
ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]

10
onbuild/README.md Normal file
View File

@@ -0,0 +1,10 @@
# JupyterHub onbuild image
If you base a Dockerfile on this image:
FROM juptyerhub/jupyterhub-onbuild:0.6
...
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.
This is how the `jupyter/jupyterhub` docker image behaved prior to 0.6.

5
readthedocs.yml Normal file
View File

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

View File

@@ -1,6 +1,6 @@
traitlets>=4
tornado>=4
traitlets>=4.1
tornado>=4.1
jinja2
simplepam
sqlalchemy
pamela
sqlalchemy>=1.0
requests

View File

@@ -1,4 +1,297 @@
#!/usr/bin/env python3
#!/usr/bin/env python
"""Extend regular notebook server to be aware of multiuser things."""
from jupyterhub.singleuser import main
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os
try:
from urllib.parse import quote
except ImportError:
# PY2 Compat
from urllib import quote
import requests
from jinja2 import ChoiceLoader, FunctionLoader
from tornado import ioloop
from tornado.web import HTTPError
try:
import notebook
except ImportError:
raise ImportError("JupyterHub single-user server requires notebook >= 4.0")
from traitlets import (
Bool,
Integer,
Unicode,
CUnicode,
)
from notebook.notebookapp import (
NotebookApp,
aliases as notebook_aliases,
flags as notebook_flags,
)
from notebook.auth.login import LoginHandler
from notebook.auth.logout import LogoutHandler
from notebook.utils import url_path_join
# Define two methods to attach to AuthenticatedHandler,
# which authenticate via the central auth server.
class JupyterHubLoginHandler(LoginHandler):
@staticmethod
def login_available(settings):
return True
@staticmethod
def verify_token(self, cookie_name, encrypted_cookie):
"""method for token verification"""
cookie_cache = self.settings['cookie_cache']
if encrypted_cookie in cookie_cache:
# we've seen this token before, don't ask upstream again
return cookie_cache[encrypted_cookie]
hub_api_url = self.settings['hub_api_url']
hub_api_key = self.settings['hub_api_key']
r = requests.get(url_path_join(
hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''),
),
headers = {'Authorization' : 'token %s' % hub_api_key},
)
if r.status_code == 404:
data = None
elif r.status_code == 403:
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
elif r.status_code >= 500:
self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
raise HTTPError(502, "Failed to check authorization (upstream problem)")
elif r.status_code >= 400:
self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Failed to check authorization")
else:
data = r.json()
cookie_cache[encrypted_cookie] = data
return data
@staticmethod
def get_user(self):
"""alternative get_current_user to query the central server"""
# only allow this to be called once per handler
# avoids issues if an error is raised,
# since this may be called again when trying to render the error page
if hasattr(self, '_cached_user'):
return self._cached_user
self._cached_user = None
my_user = self.settings['user']
encrypted_cookie = self.get_cookie(self.cookie_name)
if encrypted_cookie:
auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie)
if not auth_data:
# treat invalid token the same as no token
return None
user = auth_data['name']
if user == my_user:
self._cached_user = user
return user
else:
return None
else:
self.log.debug("No token cookie")
return None
class JupyterHubLogoutHandler(LogoutHandler):
def get(self):
self.redirect(
self.settings['hub_host'] +
url_path_join(self.settings['hub_prefix'], 'logout'))
# register new hub related command-line aliases
aliases = dict(notebook_aliases)
aliases.update({
'user' : 'SingleUserNotebookApp.user',
'cookie-name': 'SingleUserNotebookApp.cookie_name',
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
'hub-host': 'SingleUserNotebookApp.hub_host',
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
'base-url': 'SingleUserNotebookApp.base_url',
})
flags = dict(notebook_flags)
flags.update({
'disable-user-config': ({
'SingleUserNotebookApp': {
'disable_user_config': True
}
}, "Disable user-controlled configuration of the notebook server.")
})
page_template = """
{% extends "templates/page.html" %}
{% block header_buttons %}
{{super()}}
<a href='{{hub_control_panel_url}}'
class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;'
>
Control Panel</a>
{% endblock %}
{% block logo %}
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
{% endblock logo %}
"""
def _exclude_home(path_list):
"""Filter out any entries in a path list that are in my home directory.
Used to disable per-user configuration.
"""
home = os.path.expanduser('~')
for p in path_list:
if not p.startswith(home):
yield p
class SingleUserNotebookApp(NotebookApp):
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
user = CUnicode(config=True)
def _user_changed(self, name, old, new):
self.log.name = new
cookie_name = Unicode(config=True)
hub_prefix = Unicode(config=True)
hub_host = Unicode(config=True)
hub_api_url = Unicode(config=True)
aliases = aliases
flags = flags
open_browser = False
trust_xheaders = True
login_handler_class = JupyterHubLoginHandler
logout_handler_class = JupyterHubLogoutHandler
port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use
disable_user_config = Bool(False, config=True,
help="""Disable user configuration of single-user server.
Prevents user-writable files that normally configure the single-user server
from being loaded, ensuring admins have full control of configuration.
"""
)
cookie_cache_lifetime = Integer(
config=True,
default_value=300,
allow_none=True,
help="""
Time, in seconds, that we cache a validated cookie before requiring
revalidation with the hub.
""",
)
def _log_datefmt_default(self):
"""Exclude date from default date format"""
return "%Y-%m-%d %H:%M:%S"
def _log_format_default(self):
"""override default log format to include time"""
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
def _confirm_exit(self):
# disable the exit confirmation for background notebook processes
ioloop.IOLoop.instance().stop()
def _clear_cookie_cache(self):
self.log.debug("Clearing cookie cache")
self.tornado_settings['cookie_cache'].clear()
def migrate_config(self):
if self.disable_user_config:
# disable config-migration when user config is disabled
return
else:
super(SingleUserNotebookApp, self).migrate_config()
@property
def config_file_paths(self):
path = super(SingleUserNotebookApp, self).config_file_paths
if self.disable_user_config:
# filter out user-writable config dirs if user config is disabled
path = list(_exclude_home(path))
return path
@property
def nbextensions_path(self):
path = super(SingleUserNotebookApp, self).nbextensions_path
if self.disable_user_config:
path = list(_exclude_home(path))
return path
def _static_custom_path_default(self):
path = super(SingleUserNotebookApp, self)._static_custom_path_default()
if self.disable_user_config:
path = list(_exclude_home(path))
return path
def start(self):
# Start a PeriodicCallback to clear cached cookies. This forces us to
# revalidate our user with the Hub at least every
# `cookie_cache_lifetime` seconds.
if self.cookie_cache_lifetime:
ioloop.PeriodicCallback(
self._clear_cookie_cache,
self.cookie_cache_lifetime * 1e3,
).start()
super(SingleUserNotebookApp, self).start()
def init_webapp(self):
# load the hub related settings into the tornado settings dict
env = os.environ
s = self.tornado_settings
s['cookie_cache'] = {}
s['user'] = self.user
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
s['cookie_name'] = self.cookie_name
s['login_url'] = self.hub_host + self.hub_prefix
s['hub_api_url'] = self.hub_api_url
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
super(SingleUserNotebookApp, self).init_webapp()
self.patch_templates()
def patch_templates(self):
"""Patch page templates to add Hub-related buttons"""
self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo')
env = self.web_app.settings['jinja2_env']
env.globals['hub_control_panel_url'] = \
self.hub_host + url_path_join(self.hub_prefix, 'home')
# patch jinja env loading to modify page template
def get_page(name):
if name == 'page.html':
return page_template
orig_loader = env.loader
env.loader = ChoiceLoader([
FunctionLoader(get_page),
orig_loader,
])
def main():
return SingleUserNotebookApp.launch_instance()
if __name__ == "__main__":
main()

View File

@@ -83,8 +83,8 @@ setup_args = dict(
# this will be overridden when bower is run anyway
data_files = get_data_files() or ['dummy'],
version = ns['__version__'],
description = """JupyterHub: A multi-user server for Jupyter notebooks""",
long_description = "",
description = "JupyterHub: A multi-user server for Jupyter notebooks",
long_description = "See https://jupyterhub.readthedocs.org for more info.",
author = "Jupyter Development Team",
author_email = "jupyter@googlegroups.com",
url = "http://jupyter.org",
@@ -166,7 +166,7 @@ class Bower(BaseCommand):
if self.should_run_npm():
print("installing build dependencies with npm")
check_call(['npm', 'install'], cwd=here)
check_call(['npm', 'install', '--progress=false'], cwd=here)
os.utime(self.node_modules)
env = os.environ.copy()

View File

@@ -152,15 +152,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
});
});
$("#add-user").click(function () {
var dialog = $("#add-user-dialog");
$("#add-users").click(function () {
var dialog = $("#add-users-dialog");
dialog.find(".username-input").val('');
dialog.find(".admin-checkbox").prop("checked", false);
dialog.modal();
});
$("#add-user-dialog").find(".save-button").click(function () {
var dialog = $("#add-user-dialog");
$("#add-users-dialog").find(".save-button").click(function () {
var dialog = $("#add-users-dialog");
var lines = dialog.find(".username-input").val().split('\n');
var admin = dialog.find(".admin-checkbox").prop("checked");
var usernames = [];
@@ -178,6 +178,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
});
});
$("#stop-all-servers").click(function () {
$("#stop-all-servers-dialog").modal();
});
$("#stop-all-servers-dialog").find(".stop-all-button").click(function () {
// stop all clicks all the active stop buttons
$('.stop-server').not('.hidden').click();
});
$("#shutdown-hub").click(function () {
var dialog = $("#shutdown-hub-dialog");
dialog.find("input[type=checkbox]").prop("checked", true);

View File

@@ -22,18 +22,19 @@
<thead>
<tr>
{% block thead %}
{{ th("User (%i)" % users.count(), 'name') }}
{{ th("User (%i)" % users|length, 'name') }}
{{ th("Admin", 'admin') }}
{{ th("Last Seen", 'last_activity') }}
{{ th("Running (%i)" % running.count(), 'running', colspan=2) }}
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
{% endblock thead %}
</tr>
</thead>
<tbody>
<tr class="user-row add-user-row">
<td colspan="12">
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
<a id="shutdown-hub" class="col-xs-5 col-xs-offset-2 btn btn-danger">Shutdown Hub</a>
<a id="add-users" class="col-xs-2 btn btn-default">Add Users</a>
<a id="stop-all-servers" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a>
<a id="shutdown-hub" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
</td>
</tr>
{% for u in users %}
@@ -71,9 +72,13 @@
This operation cannot be undone.
{% endcall %}
{% call modal('Stop All Servers', btn_label='Stop All', btn_class='btn-danger stop-all-button') %}
Are you sure you want to stop all your users' servers? Kernels will be shutdown and unsaved data may be lost.
{% endcall %}
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
Are you sure you want to shutdown the Hub?
You can chose to leave the proxy and/or single-user servers running by unchecking the boxes below:
You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below:
<div class="checkbox">
<label>
<input type="checkbox" class="shutdown-proxy-checkbox">Shutdown proxy
@@ -108,7 +113,7 @@
{{ user_modal('Edit User') }}
{{ user_modal('Add User', multi=True) }}
{{ user_modal('Add Users', multi=True) }}
{% endblock %}

View File

@@ -17,6 +17,11 @@
{{message}}
</p>
{% endif %}
{% if message_html %}
<p>
{{message_html | safe}}
</p>
{% endif %}
{% endblock error_detail %}
</div>

View File

@@ -9,8 +9,15 @@
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
{% endif %}
<a id="start" class="btn btn-lg btn-success"
href="{{base_url}}user/{{user.name}}/"
{% if user.running %}
href="{{ user.url }}"
{% else %}
href="{{base_url}}spawn"
{% endif %}
>
{% if not user.running %}
Start
{% endif %}
My Server
</a>
{% if user.admin %}

View File

@@ -82,7 +82,7 @@
<div id="header" class="navbar navbar-static-top">
<div class="container">
<span id="jupyterhub-logo" class="pull-left"><a href="{{base_url}}"><img src='{{static_url("images/jupyter.png") }}' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
<span id="jupyterhub-logo" class="pull-left"><a href="{{logo_url or base_url}}"><img src='{{base_url}}logo' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
{% block login_widget %}

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

View File

@@ -133,6 +133,7 @@ def untag(vs, push=False):
v2 = parse_vs(vs)
v2.append('dev')
v2[1] += 1
v2[2] = 0
vs2 = unparse_vs(v2)
patch_version(vs2, repo_root)
with cd(repo_root):
@@ -165,15 +166,13 @@ def make_env(*packages):
return py
def build_sdist(py, upload=False):
def build_sdist(py):
"""Build sdists
Returns the path to the tarball
"""
with cd(repo_root):
cmd = [py, 'setup.py', 'sdist', '--formats=zip,gztar']
if upload:
cmd.append('upload')
run(cmd)
return glob.glob(pjoin(repo_root, 'dist', '*.tar.gz'))[0]
@@ -184,7 +183,12 @@ def sdist(vs, upload=False):
clone_repo()
tag(vs, push=upload)
py = make_env()
tarball = build_sdist(py, upload=upload)
tarball = build_sdist(py)
if upload:
with cd(repo_root):
install(py, 'twine')
run([py, '-m', 'twine', 'upload', 'dist/*'])
untag(vs, push=upload)
return untar(tarball)
@@ -214,14 +218,10 @@ def untar(tarball):
return glob.glob(pjoin(sdist_root, '*'))[0]
def bdist(upload=False):
def bdist():
"""build a wheel, optionally uploading it"""
py = make_env('wheel')
cmd = [py, 'setup.py', 'bdist_wheel']
if upload:
cmd.append('upload')
run(cmd)
run([py, 'setup.py', 'bdist_wheel'])
@task
@@ -233,7 +233,10 @@ def release(vs, upload=False):
shutil.rmtree(env_root)
path = sdist(vs, upload=upload)
print("Working in %r" % path)
with cd(path):
bdist(upload=upload)
bdist()
if upload:
py = make_env('twine')
run([py, '-m', 'twine', 'upload', 'dist/*'])